diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md new file mode 100644 index 0000000..78fc485 --- /dev/null +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -0,0 +1,652 @@ +# RFC: Forge S3 tenant IAM + +Status: Experimental + +Extends: [Forge S3 tenant management](./2026-06-forge-s3-tenant-management.md) + +## Authors + +- [Srdjan](https://github.com/pyropy) + +## Introduction + +Hilt gains principals, bucket policies, and principal-bound access keys, while Ingot enforces the actions those policies grant. Together, they let the Fil One console grant each member access to a set of an organization's buckets, as decided in the [bucket access by region](https://github.com/fil-one/fil-one/blob/main/docs/architectural-decisions/2026-08-bucket-policies-m2.md) ADR. + +An access key gains an optional principal. A key created with a principal derives its authority from the bucket policies that apply to that principal. A key created without one keeps the [parent RFC's](./2026-06-forge-s3-tenant-management.md) behavior and is authorized from the permissions and buckets supplied at creation. The parent RFC's tenant, bucket, delegation, and access-key route mechanics remain unchanged. + +## Motivation + +Today, a Hilt access key stores a fixed set of permissions and bucket names at creation. Its authority cannot change afterwards, and there is no object between the tenant and its keys to which a later access change can attach. The console therefore cannot grant a member access to a set of buckets and later widen or narrow that access without reissuing every key the member holds. + +For Forge, the ["Bucket access by region: scoped keys on Aurora and FTH, IAM on Forge" ADR](https://github.com/fil-one/fil-one/blob/main/docs/architectural-decisions/2026-08-bucket-policies-m2.md) defines the principal-bound authorization path: each member is represented as a principal in the storage system; a bucket's policy is the source of that principal's access to the bucket; and every key bound to the principal carries the authority those policies grant. Service keys remain outside this model and continue to use the permissions and bucket scope they were created with. + +## Concepts + +### Roles + +The parent RFC's roles apply. Swarf is added: + +| Name | Description | +| ----- | -------------------------------------------------------------------------------------------- | +| Swarf | The Forge revocation service. Hilt publishes revocations to it; Ingot consumes its firehose. | + +### Terms + +- **Member.** A user in a Fil One organization. Fil One assigns each member one of the roles Owner, Admin, Member, or ReadOnly. Hilt is unaware of these roles. +- **Principal.** A member as Hilt knows them, identified by an opaque id the console supplies, unique within the tenant. The console creates one principal per member. A principal carries no permissions, role, or key material. +- **Bucket policy.** A document attached to a bucket. Each statement has an effect, a set of principals, and a set of S3 actions. A bucket has at most one policy, which is deleted with the bucket. +- **Effective actions.** The S3 actions a principal may perform on a bucket, computed from that bucket's policy. +- **Service key.** The parent RFC's access key. It has no principal and retains the permissions and buckets supplied at creation. The console signs all of its traffic with one, member traffic and presigned URLs included. A tenant may hold several service keys. +- **Principal-bound key.** An S3 access key that authenticates a request and identifies a principal. For each bucket the principal can access, the key holds a tenant delegation for every Forge command implied by that bucket's policy. Hilt updates those delegations when the policy changes. At request time, the key's effective permissions on a bucket are exactly those granted to its principal by the bucket's current policy. + +## Goals + +1. The storage system computes a principal's effective actions on a bucket from that bucket's policy alone: `Allow` minus `Deny`, with explicit `Deny` taking precedence. +2. A policy edit changes the authority of every key bound to an affected principal without reissuing any key. +3. Before Hilt acknowledges a policy change, it publishes to Swarf every revocation required by a narrowing of a principal's effective actions or by a widening on a bucket the key already reaches. +4. The console signs every request, presigned URLs included, with a service key, and refuses a scoped member's request itself from the principal's effective actions. A member's own keys are bound to their principal. +5. One policy model governs every principal. Existing keys continue to work, and a tenant can migrate to principals without reissuing keys or introducing a window in which the network rejects requests. + +## Hilt - Tenant API + +All management API additions and changes defined by this RFC MUST be reflected in the [Service Orchestrator Management API](https://github.com/fil-one/fil-one/blob/main/docs/service-orchestrator-integration/management-openapi.yaml) OpenAPI specification as part of the implementation. + +`POST /tenants/{tenantId}/access-keys` creates both key types. The request body has one of two shapes, and that shape determines the key type: + +| Body | Kind | Authority | +| ---------------------------------------------- | ------------------- | ----------------------------------------------------------------------------- | +| `{ name, permissions, buckets?, expiresAt? }` | Service key | The `permissions` and `buckets` in the request, as the parent RFC specifies. | +| `{ name, principalId, expiresAt? }` | Principal-bound key | Whatever the bucket policies give the principal, held as delegations that follow the policies. | + +Hilt MUST return 422 when a request includes `principalId` together with `permissions` or `buckets`. A principal-bound key has no permissions of its own, so accepting those fields would imply authority that the key does not carry. The management API requirement that `permissions` be present applies only to the service-key shape. + +The request body carries no type field; responses do (see [principal-bound access keys](#principal-bound-access-keys)). The console already sends `{ name, permissions, buckets? }` to this route, and a required field would break that call. + +The existing `GET`, `DELETE`, and tenant key-list operations apply to both key types. + +### Service keys + +A service key is the parent RFC's access key, unchanged. Hilt holds its delegations from the tenant, one per bucket and Forge command derived from its permissions, or the corresponding powerline delegations when the key has no bucket list. + +A service key: + +- Reaches the buckets in its list, or every bucket in the tenant when no list is present. It is never named in a policy statement, and no policy is evaluated for it. +- MAY hold `s3:CreateBucket` and `s3:DeleteBucket`, which no policy grants (see [action vocabulary](#action-vocabulary)). +- Appears in `GET /tenants/{tenantId}/access-keys`, counts toward `accessKeyCount`, and is deleted through the access-key delete route. +- Keeps the parent RFC's optional `expiresAt`. Rotation consists of minting a new key, moving the caller to it, and deleting the old key. + +Because its bucket list is fixed at creation, a service key scoped to named buckets cannot reach buckets created later. The console therefore holds a service key with no bucket list. + +Deleting a service key follows the parent RFC: Hilt publishes a revocation for each delegation, then deletes the key. + +### Principals + +Routes, all under `/tenants/{tenantId}` and authenticated with the partner key: + +| Method | Path | Purpose | +| ------ | --------------------------------------- | -------------------------------------------------------------------------------- | +| PUT | `/principals/{principalId}` | Create the principal: 201; 200 if it exists. Idempotent. | +| GET | `/principals` | List principals. | +| GET | `/principals/{principalId}` | Principal detail. | +| DELETE | `/principals/{principalId}` | Remove the principal (see [removal](#principal-removal)). 204 if already gone. | +| GET | `/principals/{principalId}/policies` | Every bucket policy with a statement naming the principal or `*`. | +| GET | `/principals/{principalId}/access` | The principal's effective actions per bucket. | + +A principal-bound key is created through the tenant access-key route with `principalId` set, and listed through the same route with a `principalId` filter (see [principal-bound access keys](#principal-bound-access-keys)). + +Hilt returns 422 for an empty `principalId`, one longer than 255 characters, or the reserved id `*`. + +A principal is represented as: + +```jsonc +{ + "principalId": "8f2c...", + "createdAt": "2026-09-09T10:00:00Z" +} +``` + +A principal has no DID, vault entry, or delegation and appears in no UCAN. Policy authority reaches the storage system through the principal's keys: each key holds tenant delegations over the buckets its principal may access, and on each request Hilt evaluates the bucket policy for that principal and re-delegates from the key to Ingot. As with a service key, the proof chain runs from the bucket to the tenant to the key to Ingot. + +Ingot caches a principal-bound key exactly as it caches a service key: proof chains, the effective actions on the addressed bucket, the derived signing key, and the tenant, all until the next UTC midnight (see [Ingot](#ingot---s3-api)). Ingot consults Hilt on a cache miss and after a revocation. Requests from a warm key proceed from Ingot to Sprue and Piri without a Hilt round trip. [Alternative designs were considered](#alternatives-considered). + +The principal's effective actions are read through `GET /principals/{principalId}/access`: + +```jsonc +{ + "buckets": [ + { "name": "photos", "actions": ["s3:GetObject", "s3:ListBucket"] }, + { "name": "backups", "actions": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"] } + ] +} +``` + +Buckets with an empty effective set are omitted. The read reflects Hilt's latest committed write. + +A removed principal remains as a tombstone (see [removal](#principal-removal)). It is absent from `GET` and list results, holds no keys, and is named in no statement. A `PUT` for the same id revives it with no attached state. + +### Bucket policies + +A bucket has at most one policy. It is read and written over S3 with a service key, and Ingot forwards each operation to Hilt as a [`/s3/bucket/policy`](#s3bucketpolicy) invocation: + +| Operation | Request | Service-key permission | Purpose | +| -------------------- | ------------------------- | ----------------------- | -------------------------------------------------------------- | +| `GetBucketPolicy` | `GET /{bucket}?policy` | `s3:GetBucketPolicy` | Read the policy. Returns an `ETag`. | +| `PutBucketPolicy` | `PUT /{bucket}?policy` | `s3:PutBucketPolicy` | Create or replace. May carry `If-None-Match: *` or `If-Match`. | +| `DeleteBucketPolicy` | `DELETE /{bucket}?policy` | `s3:DeleteBucketPolicy` | Delete. May carry `If-Match`. | + +A principal-bound key receives `AccessDenied` from all three; no policy grants them (see [action vocabulary](#action-vocabulary)). A bucket created with `aws s3 mb` gets its policy with `aws s3api put-bucket-policy` and the same service key, or through the console. + +The document follows the shape of an AWS bucket policy, with AWS's field names and capitalization, limited to the fields Forge evaluates. It is the body of `PutBucketPolicy` and of the `GetBucketPolicy` response: + +```jsonc +{ + "Statement": [ + { + "Sid": "filone-owners", // optional label; Hilt stores and returns it, nothing evaluates it + "Effect": "Allow", // "Allow" | "Deny" + "Principal": ["8f2c...", "a91e..."], // principal IDs, or the string "*" for every principal of the tenant + "Action": ["s3:GetObject", "s3:ListBucket"] // policy vocabulary, or ["s3:*"] for all of it + }, + { + "Effect": "Deny", + "Principal": "*", + "Action": ["s3:PutObjectRetention", "s3:PutObjectLegalHold"] + } + ] +} +``` + +Fil One writes its default statements under the labels `filone-owners`, `filone-admins` and `filone-creator`, and finds them again by those labels when a member's role changes. To Hilt they are ordinary labels: any statement may carry any `Sid`. + +Hilt MUST refuse with `InvalidBucketPolicy`, which Ingot renders as `MalformedPolicy` (400): + +- an action outside the [policy vocabulary](#action-vocabulary); in particular `s3:CreateBucket`, `s3:DeleteBucket`, and `s3:ListAllMyBuckets`. `s3:*` is accepted and stands for the whole vocabulary, +- a `Principal` that is neither the string `"*"` nor a non-empty list of ids of live principals of the tenant. `"*"` inside a list is rejected; the wildcard has one spelling, +- an empty `Statement` list or a statement with an empty `Action` list. To remove a policy, the caller sends `DeleteBucketPolicy`, +- a field the schema does not define. There is no `Resource` field: the policy governs the bucket it is stored on. + +A bucket that does not exist or belongs to another tenant is `UnknownBucket` on all three operations, which Ingot renders as `NoSuchBucket` (404). `GetBucketPolicy` and `DeleteBucketPolicy` on a bucket that has no policy are `PolicyNotFound`, rendered as `NoSuchBucketPolicy` (404). + +Hilt MUST accept a `Deny` statement whose `Principal` is `"*"`. Hilt does not derive the Fil One role or identity of the member who initiated a policy write. The console is responsible for deciding whether that member may write such a statement before sending the write. + +**Evaluation.** The effective actions of principal `p` on bucket `b` are + +``` +effective(p, b) = union(Action of Allow statements naming p or "*") + \ union(Action of Deny statements naming p or "*") +``` + +where `s3:*` expands to every action in the policy vocabulary. Hilt returns the set in sorted order. A bucket with no policy has an empty effective set for every principal. Service keys are not principals, so bucket policies are never evaluated for them. + +`s3:ListAllMyBuckets` sits outside the per-bucket action set and is granted to every principal. The S3 `ListBuckets` operation therefore requires no policy lookup. This is distinct from listing objects within a bucket, which requires `s3:ListBucket` and is granted through the bucket policy like any other action. + +**Canonical form.** The `Statement`, `Principal`, and `Action` lists are sets. Hilt removes duplicates and sorts them on write, and stores and returns the sorted document, with statements in the order of their CIDs. The ETag is the CID (DAG-JSON codec, sha256) of a DAG-JSON encoding that represents each set as a map. A statement encodes its principals and actions as maps keyed by principal ID and by action, with the wildcard principal as the single key `"*"`. The policy is a map keyed by the CID of each encoded statement. DAG-JSON sorts map keys, so the encoding is independent of order and duplicates. Two documents that differ only in order or in duplicates have the same ETag. + +**Compare-and-set.** AWS defines no preconditions on these operations, and a request without one is unconditional, as on AWS. Forge adds them. `GetBucketPolicy` returns the `ETag`. `PutBucketPolicy` and `DeleteBucketPolicy` accept `If-Match` with that value, or `If-None-Match: *` on a `PutBucketPolicy` that creates the first policy. A precondition header MUST be among the request's `SignedHeaders`. A request with both preconditions, another `If-None-Match` value, or an unsigned precondition is refused with `InvalidPrecondition`, rendered as `InvalidRequest` (400). A mismatch, or `If-None-Match: *` when a policy exists, is `PreconditionFailed` (412) and nothing is written. A successful `PutBucketPolicy` answers 204 with the new `ETag` in the response header. Callers treat the ETag as opaque. The console sends a precondition on every write. + +**Storage.** Hilt indexes each policy by the principals it names, so the two principal reads above are index lookups. The policy is deleted with the bucket. + +### Bucket creation + +Buckets are created and deleted over S3 only with a service key. `CreateBucket` cannot be granted by a bucket policy because the target bucket does not exist yet. `DeleteBucket` is excluded from the policy vocabulary so that bucket lifecycle stays with service keys, as the ADR decides. A principal-bound key receives `AccessDenied` from both operations. `aws s3 mb` with a member's key fails, and members create buckets through the console. + +A new bucket has no policy, and only service keys reach it until one is written with `PutBucketPolicy`. The console creates a bucket in two S3 requests signed with its service key: `CreateBucket`, then `PutBucketPolicy` with `If-None-Match: *` and a document naming the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. It answers the member after both complete. A failure between the two leaves a bucket that no member can reach and that the console lists with no policy; the console finishes the policy write from a durable job (see [error recovery](#error-recovery)). A bucket created by a generic S3 client gets its policy the same way, from that client or from the console. + +### Principal-bound access keys + +`POST /tenants/{tenantId}/access-keys` + +```jsonc +{ + "name": "laptop", // unique per principal + "principalId": "8f2c...", // makes the key principal-bound + "expiresAt": "2026-12-31T00:00:00Z" // optional +} +``` + +`principalId` is carried in the request body rather than the path so that the existing `POST /tenants/{tenantId}/access-keys` route can create both key types without breaking the console's current calls. The request body shape determines the key type. Listing follows the same rule: the tenant key-list route serves both kinds, filtered by principal when asked. A second route, `POST /tenants/{tenantId}/principals/{principalId}/access-keys`, is discussed under [alternatives](#separate-creation-routes-per-kind). + +Hilt MUST generate and store the key exactly as the parent RFC specifies and record its principal. The key has no permissions or bucket list of its own. Hilt returns 422 when `principalId` does not identify a live principal of the tenant and 409 when the name is already used by another key of that principal. The response is the parent RFC's `CreatedAccessKey` with `type` set to `"principal"` and a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. + +Hilt MUST also issue the key its delegations and store them with the tenant's other delegations. It reads the principal's effective actions per bucket, the computation behind `GET /principals/{principalId}/access`, and for every bucket with a non-empty set issues one delegation per Forge command those actions map to (see [action vocabulary](#action-vocabulary)): + +```jsonc +{ + "iss": "did:plc:tenant", + "aud": "did:key:accessKey", + "sub": "did:key:bucket", + "cmd": "/blob/add", + // exp: the key's expiresAt, or none +} +``` + +The stored set for a key and a bucket is always the Forge commands its principal's effective actions on that bucket map to. Every write in this RFC keeps that true, so a request that passes the action check always resolves a chain, and a key whose principal reaches no bucket holds no delegations. Every delegation carries a random nonce, so a reissued set never carries a CID that was revoked. A service key scoped to named buckets holds delegations of the same shape, derived from its permission list. + +A service key name remains unique within the tenant. A principal-bound key name is unique only within its principal, so two members may each hold a key named `laptop`. + +`GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return a `type` field, `"service"` or `"principal"`. A principal-bound key also carries `principal`; a service key carries its own `permissions` and `buckets`. The list accepts a `principalId` query parameter and then returns only that principal's keys. The key list does not carry each key's effective actions; the console reads them once per principal through the principal route. + +`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` deletes either kind in the same sequence: a revocation for each of the key's delegations, published in one Swarf request, then the key. A deletion and a rotation of the same key are serialized. Ingot drops that key's per-key cache and no other. + +Both key types count toward `accessKeyCount`. This RFC adds no key limit. + +### Policy changes and delegation rotation + +A `PutBucketPolicy` or `DeleteBucketPolicy` first determines the affected principals from the union of the old and new documents. If either document contains a statement whose `Principal` is `"*"`, the affected set expands to every live principal in the tenant. Hilt computes each affected principal's effective actions on the bucket before and after the change. If the set changed, Hilt rewrites every bound key's delegations over that bucket before acknowledging the write: it publishes a revocation for each existing delegation, then issues and stores the delegations required by the new effective set. A revocation is a `/ucan/revoke` invocation sent to Swarf and signed by the tenant that issued the delegation. All revocations produced by one policy write are sent in a single Swarf request (see [Swarf](#swarf)). Delegations for other buckets are unchanged. + +A principal's first grant on a bucket publishes no revocation because the key previously held no delegation for that bucket. Ingot likewise has no cached action set for a bucket it has never seen under that key, so the next request reaches Hilt and is evaluated against the new policy. Widening access on a bucket the key already reaches requires the same rotation as narrowing access: Ingot rejects actions outside its cached set locally, so without rotation the new grant would not become visible until the cache expired at midnight. + +Ingot's per-key cache contains the proof chains, effective action sets, derived signing key, and tenant, and every proof chain includes one of the key's stored delegations. When Ingot consumes a revocation for any delegation in those proof chains, it drops all cached state for that access key. As a result, revoking a key's delegations for one bucket also clears that key's cache for every other bucket; the next request returns to Hilt and refills the cache from the current policy. Revocation remains exact at Hilt and Swarf, while Ingot pays the broader cost of a cache refill. + +**Concurrency.** A policy write runs in one transaction, publishes its revocations inside it, and commits only afterwards. Hilt serializes the write with the authorize and bucket-info reads of the keys it affects, with the creation of keys for the principals it affects, with other writes to the same bucket's policy, and with the removal of those principals. The guarantees that follow are the contract: + +- An authorize request that arrives after the revocations are published waits for the commit and is answered from the new policy with the new delegations. +- A key created while the write is in flight is either included in the rotation or created from the committed policy. +- A proof chain is never paired with an action set from another revision of the policy. +- Two writes to one bucket's policy never interleave, and a principal removal and a policy write naming that principal never wait on each other (see [principal removal](#principal-removal)). + +Without the first guarantee an authorize request during a write would be answered from the old policy with delegations Ingot has already seen revoked, and every request from that key would reach Hilt until the commit (see [error recovery](#error-recovery)). The cost is that authorize requests for a bucket may wait one Swarf round trip during a write to its policy. A write that cannot complete within its timeout fails with `ConcurrentChange`, which Ingot renders as `OperationAborted` (409); an authorize that cannot proceed within its timeout fails with `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). + +An authorize request that completed before the write began but whose response reaches Ingot after the revocations is outside these guarantees; [error recovery](#error-recovery) covers it. + +The staleness bound for a policy change is the interval between Hilt acknowledging the change and Ingot consuming the corresponding record via Swarf. It is bounded by Swarf latency and is independent of the midnight cache horizon. A revocation clears only Ingot's caches. Sprue and Piri do not consult Swarf, so a per-request delegation already issued by Ingot remains valid there until it expires. That does not extend usable access beyond Ingot: Ingot is the delegation's audience and signs every invocation that carries it. Once revocation clears Ingot's cache, the next request returns to Hilt and is evaluated against the new policy. + +### Error recovery + +Every write that publishes revocations (a policy change, a key's deletion, a bucket's deletion) runs in one transaction and publishes before it commits. A principal's removal and a tenant's removal run in steps that are each idempotent (see [principal removal](#principal-removal) and [tenant removal](#tenant-removal)). Hilt enforces exactly the state it stores, so a failed write leaves nothing at Hilt to reconcile. What it leaves is the caller's unmet intent. The console owns completing it and SHOULD retry from a durable job rather than from the member's request, so that a member who gives up after one attempt does not leave a removal or a policy change unapplied. + +**Swarf rejects the publish, or the publish fails.** Hilt returns 500 and commits nothing. The old state remains in force, so no principal receives authority beyond the committed state. + +**Swarf accepts the revocations and the commit fails.** The delegations are revoked at Swarf and still stored in Hilt. Ingot consumes the revocations, drops each affected key's cache, and caches the revoked CIDs (see [Ingot](#ingot---s3-api)). On the key's next request Ingot calls Hilt, receives the same stored delegations, authorizes the request from them, and caches nothing. Every request from that key reaches Hilt until a committed write returns delegations with new CIDs. The member holds exactly the access the committed state grants, at one Hilt round trip per request. + +The console's retry republishes the same revoke invocations, which Swarf records as duplicates and does not emit again. The committed write stores fresh delegations with new CIDs, and the key's next request caches them. The change reaches Ingot one request after the commit. + +If the console stops retrying, the old state stays in force and Hilt holds nothing partially applied. The key pays a Hilt round trip per request until midnight, when Ingot forgets the revoked CIDs and caches the stored delegations again. A retry that commits after that point reaches Ingot at the following midnight. An Ingot restart has the same effect: it forgets the revoked CIDs with the cache, and a retry that commits afterwards reaches the key at midnight. + +**A response that outran a write.** An authorize response that left Hilt before a write began can arrive at Ingot after the write's revocations. Its delegations are among the revoked CIDs, so Ingot serves that one request under the old state and caches nothing. + +### Principal removal + +`DELETE /tenants/{tenantId}/principals/{principalId}`, in order: + +1. Publishes a revocation for every delegation of each of the principal's keys, in one Swarf request. +2. Removes the principal from every statement naming it. A statement left with no principal is deleted, and a policy left with no statement is deleted. Each rewrite is a policy write that carries the ETag it read. +3. Locks the principal. From here a policy write naming the principal waits for the outcome. A policy that names the principal again was written after step 2, and the removal returns `ConcurrentChange`. +4. Publishes a revocation for any delegation the principal's keys gained since step 1, deletes the keys, marks the principal removed, and commits. + +Steps 1 and 2 run before the principal is locked so that a removal and a policy write naming the principal never wait on each other. + +The principal remains as a tombstone, so a removed id keeps its `createdAt` and a second `DELETE` or a later `PUT` finds it. A removed principal is absent from `GET` and the list, is refused with 422 as the `principalId` of a new key, and is skipped by evaluation should a statement still name it. An authorize request for one of its keys waits until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. + +A failure stops the removal at that step, and the console retries (see [error recovery](#error-recovery)). A failed publish in step 1 changes nothing at Hilt. A failure after step 2 leaves the principal with its keys, whose delegations are revoked, and without the statements already rewritten; the retry finishes the removal. The revocations already published stand. The call answers 204 once the principal is removed, and 204 for an id that is removed or never existed. + +A `PUT` for a removed principal's id revives it. The principal returns with no keys and named in no statement, so nothing of its earlier access survives, whether the id belongs to the same member re-invited or to a different member given the same id. + +### Bucket deletion + +`/s3/bucket/delete` deletes the bucket with its delegations and policy. Hilt revokes every service-key and principal-bound-key delegation over the bucket in a single Swarf request. Ingot removes the bucket from its local database as part of the same request, and local authorization rejects any bucket that is no longer present, so cached chains for a deleted bucket cannot be used. + +### Tenant removal + +`DELETE /tenants/{tenantId}` removes a disabled tenant. It MUST publish a revocation for every delegation the tenant's keys hold before it deactivates the tenant's `did:plc`, because the tenant signs the revocations and Swarf verifies them against the tenant's DID. It then deletes the tenant's principals, policies, keys, buckets, and delegations. The steps are idempotent: a retry that finds the DID already deactivated skips the publish, which the earlier attempt completed before deactivating, and finishes the deletion. Ingot drops the per-key cache for every tenant key when the records arrive. + +## Hilt - UCAN API + +The commands and their argument and result types retain their existing shapes. The authorization steps gain a branch on how the permitted actions are derived, and one new command carries the policy operations. + +### `/s3/request/authorize` + +For a request signed with a service key, the parent RFC's authorization steps apply unchanged: the action must appear in the key's `permissions`, and the bucket must appear in its `buckets` unless the key has no bucket list. + +For a request signed with a principal-bound key, Hilt performs the parent RFC's steps through signature verification, tenant status, issuer, and region, then: + +1. Derive the S3 operation and its action from method, path, and query (see [action vocabulary](#action-vocabulary)). +2. Load the key's principal. A key whose principal is removed is refused with `UnknownAccessKey`. +3. If the operation is `ListBuckets`, authorize it: every principal holds `s3:ListAllMyBuckets`. +4. If the operation is `CreateBucket` or `DeleteBucket`, refuse with `OperationNotPermitted`. No policy grants them. +5. Resolve the bucket by name. If it does not exist, or belongs to another tenant, refuse with `UnknownBucket`. +6. Compute `effective(principal, bucket)`. If it is empty, refuse with `UnknownBucket`. +7. If the action is not in the effective set, refuse with `OperationNotPermitted`. A copy evaluates the source bucket against its own policy the same way. +8. Re-delegate from the key to Ingot, one per-request delegation per Forge command mapped from the action, signed with the access key as the parent RFC's path does: + +```jsonc +{ + "iss": "did:key:accessKey", + "aud": "did:web:ingot", // the invocation issuer + "sub": "did:key:bucket", + "cmd": "/content/retrieve", + // exp: next UTC midnight plus clock skew, capped at the key's expiry +} +``` + +Expiry follows the parent RFC. The response container carries the per-request delegations, which are not stored, each keyed to itself as the parent RFC's path does; Ingot obtains the chain from the bucket root through the key's stored delegations over `/s3/bucket/info`. A request that passes step 7 always resolves a chain, because the key's stored delegations over the bucket are exactly the commands its effective actions map to. + +**Result.** The parent RFC's `AuthorizeOK`, with `tenant` carrying the tenant and `permissions` carrying the key's effective actions on the addressed bucket, sorted. For `ListBuckets` the effective actions are `["s3:ListAllMyBuckets"]`. For a service key `permissions` carries the key's own permission set, as the parent RFC specifies. + +### `/s3/bucket/info` + +For a principal-bound key, the chain consists of the bucket root, the bucket-to-tenant delegation with `cmd: "/"`, and the key's stored delegations over that bucket, matching a service key scoped to named buckets. The access key is a hop in the chain; the principal itself appears in no UCAN. + +`InfoOK` keeps its shape; `permissions` carries the key's effective actions on the bucket. A key whose principal cannot reach the bucket receives `UnknownBucket`. + +### `/s3/bucket/create` and `/s3/bucket/delete` + +A service key reaches them by holding `s3:CreateBucket` or `s3:DeleteBucket` in its permissions. A principal-bound key receives `OperationNotPermitted` from both. Create is otherwise unchanged. Delete removes the policy with the bucket (see [bucket deletion](#bucket-deletion)). + +### `/s3/bucket/policy` + +* Issuer: Ingot +* Audience: Hilt +* Subject: Hilt + +Carries `GetBucketPolicy`, `PutBucketPolicy`, and `DeleteBucketPolicy`. The request's method selects the operation. + +#### Arguments + +**IPLD schema** + +```ipldsch +type PolicyArguments struct { + request Request # the forwarded S3 request + body optional Bytes # the policy document, on a PUT +} +``` + +
+Go syntax + +```go +type PolicyArguments struct { + Request Request `cborgen:"request"` + Body []byte `cborgen:"body"` +} +``` +
+ +Hilt verifies the signature as `/s3/request/authorize` does and requires a service key holding the operation's permission. On a `PUT` it checks the body against the request's signed `x-amz-content-sha256`, so nothing on the path can replace the document. It then validates the document, applies the precondition, and writes as [bucket policies](#bucket-policies) and [policy changes](#policy-changes-and-delegation-rotation) describe. + +#### Result + +`GET` returns the stored document and its `ETag`. `PUT` returns the new `ETag`. `DELETE` returns the result with both empty. + +```jsonc +{ + "etag": "bafy...", + "policy": { "Statement": [ /* ... */ ] } // GET only +} +``` + +### `/s3/bucket/list` + +Unchanged. The operation returns the tenant's complete bucket set, matching AWS behavior in which a listing may include bucket names the caller cannot open. The console filters its own list against the principal's effective actions. + +### Failures + +`UnknownBucket` covers a bucket that does not exist, belongs to another tenant, or is out of the principal's reach. `OperationNotPermitted` covers an action outside a non-empty effective set and the two bucket operations no policy can grant. `UnknownAccessKey` covers a principal-bound key whose principal is removed. Ingot's existing mapping renders them as `NoSuchBucket` (404), `AccessDenied` (403), and `InvalidAccessKeyId` (403). + +This RFC adds these failures: + +| Hilt failure | Meaning | Ingot renders as | +| ------------------------ | -------------------------------------------------------------------------- | ---------------------------- | +| `TemporarilyUnavailable` | An authorize could not proceed within its timeout. | `ServiceUnavailable` (503) | +| `InvalidBucketPolicy` | A `PutBucketPolicy` document failed validation. | `MalformedPolicy` (400) | +| `PolicyNotFound` | A read or delete of a bucket that has no policy. | `NoSuchBucketPolicy` (404) | +| `PreconditionFailed` | An `If-Match` or `If-None-Match` did not hold. | `PreconditionFailed` (412) | +| `InvalidPrecondition` | A precondition was unsigned, doubled, or malformed. | `InvalidRequest` (400) | +| `ConcurrentChange` | A policy write could not complete within its timeout. | `OperationAborted` (409) | + +The client retries on 503 and 409. + +## Swarf + +The Swarf service requires no changes. A rotation, a key's deletion, a principal's removal, and a bucket's deletion publish `/ucan/revoke` for each delegation they invalidate, a UCAN command Swarf serves on its RPC endpoint, self-signed by the tenant that issued the delegation, with no witness path. That is the form of every revocation Hilt publishes. Hilt skips a delegation that has already expired: Swarf would refuse the revocation, and an expired delegation grants nothing. Swarf validates the delegation's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. + +All revocations produced by one write are sent to Swarf in one request, one `/ucan/revoke` invocation per delegation (ucantone's `ExecuteBatch`). Hilt checks every receipt, so any rejected revocation fails the write. A `"*"` policy edit therefore costs one Swarf round trip however many keys it rotates, and the request grows with the fan-out (see [open questions](#open-questions)). + +## Ingot - S3 API + +Ingot's request path is unchanged: it consults Hilt on a cache miss and otherwise authorizes locally from the cached state. + +For each access key, Ingot caches the data carried by Hilt's authorize response: delegations, the effective action set for each bucket Hilt names, the derived SigV4 verification key, and the tenant. The cache lasts until the next UTC midnight plus clock skew. On the fast path, Ingot verifies the signature with the cached key, checks that the requested action is present in the bucket's cached effective set, and requires a chain to Ingot's agent for every Forge command to which the action maps. If a cached entry exists but the action is absent or a required chain does not resolve, Ingot rejects the request locally; it does not consult Hilt. + +The cached effective set is what makes `Deny` enforceable. Several S3 actions map to the same Forge commands: for example, `s3:GetObject` and `s3:ListBucket` both require `/content/retrieve`, while `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain lookup alone therefore cannot distinguish between actions that share the same commands or honor a policy that grants one but not the other. The cached action set provides that distinction. A request for a bucket with no cached set goes to Hilt. + +Operations that map to no Forge command are authorized at Hilt on every request: `ListBuckets`, `CreateBucket`, `DeleteBucket`, `GetBucketPolicy`, `PutBucketPolicy`, and `DeleteBucketPolicy`. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. Ingot forwards the three policy operations as `/s3/bucket/policy` invocations, with the request body on a `PUT`. + +**The firehose consumer caches revocations.** When Swarf reports a revoked CID, Ingot drops every per-key cache whose proof chains contain that delegation and keeps the CID in memory until the next UTC midnight plus clock skew, the cache's own horizon. Every cached proof chain for a principal-bound key contains one of the key's stored delegations, so a rotation always names something the cache contains. A revocation that matches no cache is cached all the same. On a cache miss, Ingot checks the delegations in Hilt's response against the cached CIDs. If any is present, Ingot authorizes the request from the response and caches nothing, so a key whose revocations were published without a commit reaches Hilt on every request until a committed write issues fresh delegations (see [error recovery](#error-recovery)). The cached revocation CIDs only stop Ingot caching a response; Hilt's committed state decides the request. + +Error mapping gains the rows listed under [failures](#failures). `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. + +## Fil One console + +The console holds one service key per tenant. The key is created without a bucket list and holds every action in the management API's permission enum: the object actions it signs on members' behalf, `s3:CreateBucket`, `s3:DeleteBucket`, and the three policy actions. It signs every request the console sends: tenant setup, bucket creation and deletion, policy reads and writes, background workers, and every member's bucket and object traffic, presigned URLs included. + +Bucket policies are never evaluated for a service key, so on a region serving the `iam` model the console enforces a scoped member's access itself. Before it signs a request on a member's behalf, it reads the member's effective actions through `GET /principals/{principalId}/access`, filters bucket listings and the activity feed to the buckets in that result, and refuses a bucket-addressed request whose bucket is absent from it or whose action the bucket's effective set does not include. Owners and Admins are named on every bucket's policy and are served without the check. + +Consequences: + +- The console's own check is the whole of the enforcement for console traffic. Hilt and Ingot enforce the policies directly for keys bound to a principal, which members mint for their own use. +- An Owner or Admin can also create a service key through the console. It is the parent RFC's key, authorized from the permissions and buckets given at creation and never from a policy, and the console caps it at its creator's role and deletes it when a role change narrows the creator below the key's scope. +- A presigned URL is authorized when the console issues it and redeems under the service key until it expires. A member removed from a bucket keeps a link they already hold for its remaining life. +- A policy change reaches a member's console traffic on their next request, because the console reads the effective actions from Hilt's committed state. It reaches the member's own keys through the rotation of those keys' delegations. +- The console mints nothing on a member's first request and deletes nothing with the principal beyond the keys the member minted. + +## Action vocabulary + +The policy vocabulary is Hilt's S3 permission set excluding the bucket-level actions, plus a wildcard: + +| S3 action | Forge commands | Note | +| ------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- | +| `s3:GetObject` | `/content/retrieve` | | +| `s3:GetObjectVersion` | `/content/retrieve` | | +| `s3:GetObjectRetention` | `/content/retrieve` | | +| `s3:GetObjectLegalHold` | `/content/retrieve` | | +| `s3:PutObject` | `/blob/add`, `/index/add`, `/upload/add`, `/content/retrieve`, `/blob/abort`, `/blob/remove` | | +| `s3:PutObjectRetention` | `/blob/add`, `/index/add`, `/content/retrieve` | no `/blob/remove` | +| `s3:PutObjectLegalHold` | `/blob/add`, `/index/add`, `/content/retrieve` | no `/blob/remove` | +| `s3:DeleteObject` | `/blob/remove`, `/upload/remove` | | +| `s3:DeleteObjectVersion` | `/blob/remove`, `/upload/remove` | | +| `s3:ListBucket` | `/content/retrieve` | | +| `s3:ListBucketVersions` | `/content/retrieve` | | +| `s3:ListBucketMultipartUploads` | `/content/retrieve` | | +| `s3:ListMultipartUploadParts` | `/content/retrieve` | | +| `s3:AbortMultipartUpload` | `/blob/abort`, `/blob/remove` | | +| `s3:*` | every row above | policy documents only | + +Excluded from policies: `s3:CreateBucket` and `s3:DeleteBucket`, because a principal holding them would act outside the policy that granted it; `s3:GetBucketPolicy`, `s3:PutBucketPolicy`, and `s3:DeleteBucketPolicy`, because a principal holding them could rewrite the policy that granted it; and `s3:ListAllMyBuckets`, which every principal holds. `s3:*` never expands to any of them. A service key holds the bucket and policy actions through its own permissions, which is how the console creates and deletes buckets and writes policies. + +Bucket-configuration reads (`GET /{bucket}?versioning`, `GET /{bucket}?object-lock`) classify as `ListBucket`, so `s3:ListBucket` covers them and Ingot's fast path applies. The management API's action enum gains `s3:AbortMultipartUpload` and `s3:ListMultipartUploadParts`, which Hilt already accepts, and the three policy actions. + +Retention and legal-hold writes pass through the API like any other action. The rule that only an Owner may grant them is the console's, and Hilt does not know it. + +## Migration + +Every existing key becomes a service key because a key without a principal is exactly the key defined by the parent RFC. Existing keys are preserved because they are embedded in customer workflows. They continue to work unchanged and can be rotated by customers on their own schedule. + +**Once per Forge network.** + +1. Deploy Ingot with the policy operations, the new error mappings, and the revocation cache. Ingot already enforces the cached effective action set. +2. Deploy Hilt. Its schema migration adds principals, policies, and the key's principal. Every existing key keeps authorizing through the parent RFC's path, and nothing is backfilled. + +**Once per organization.** The console creates one principal per member with `PUT /tenants/{tenantId}/principals/{principalId}`, then writes a policy for each bucket naming the organization's Owners and Admins. The console signs member traffic with its service key throughout, and service keys are not evaluated against bucket policies, so this step changes no member's access. + +**Once per region.** After every organization in the region has principals and policies, the console switches that region's access model from `scoped-keys` to `iam`. The access model is configured per region; policy operations, the policy fan-out, and the console's check of member traffic against effective actions are active only where the model is `iam`. From that point forward, the console writes a policy on every bucket it creates and refuses a scoped member's request outside their effective actions. + +No migration step takes an existing key out of service. + +## Alternatives considered + +### A principal invalidation record + +Swarf could add a `/principal/invalidate` command and a `principal` firehose event carrying a tenant and `principalId`, while Ingot's consumer could drop every per-key cache whose authorize result carried that pair. A policy change would publish one record per changed principal rather than one revocation per key, and Hilt would store nothing per key. Nothing in the record is a delegation, so Swarf could check no proof and would have to trust a configured publisher list. Ingot would index its per-key caches by tenant and principal, and one key's deletion would clear every key the principal holds. Policy-derived delegations cost more rows and revocations per change and change neither Swarf nor Ingot's consumer, and what they revoke is a grant. + +### A marker delegation + +Each principal-bound key could hold one tenant-issued delegation for a command no service handles. Every authorize response would carry that delegation so Ingot's per-key cache always contains it, and policy changes would rotate it. It costs one row per key and makes every change one revocation per key. The revoked delegation grants nothing, so Ingot dropping the per-key cache on its revocation rests on an agreement between Hilt and Ingot recorded only here, and Sprue and Piri never see it. Policy-derived delegations revoke the grant that changed. + +### Recorded per-request delegations revoked on narrowing + +Hilt could persist every per-request delegation it issues and revoke the outstanding delegations for a principal and bucket when access narrows. The revocation would be exact to the bucket, and downstream services would never see a wider grant than the policy. It adds a table that churns once per key per bucket per day, and the stored delegations already name the bucket and command, so it adds no precision. Revoking them would also cut in-flight work at Sprue and Piri. + +### A did:key principal in the chain + +Each principal could instead be an ed25519 key stored by Hilt, with one tenant delegation included in every proof chain and the principal delegating onward to its access keys. A policy change would then rotate one delegation per principal rather than one set per key, and a did:key principal could sign its own invocations one day. It costs a vault entry and a stored delegation per member, a hop in every proof chain, and a chain that names the tenant's whole authority rather than the buckets the policies grant. Delegating to the key keeps principals free of key material and revokes exactly the grant a policy change removes. + +### Policies evaluated at Ingot + +Ingot embeds versitygw, whose policy engine is currently disabled by assigning the admin role to every authenticated request. Storing policies at Ingot and enabling that engine would evaluate them at the edge with no Hilt round trip. It puts the rule outside the system that owns it, requires policy persistence and replication at every Ingot instance, and leaves Hilt unable to issue delegations that match the decision. + +### Separate creation routes per kind + +`POST /tenants/{tenantId}/service-credentials` and `POST /tenants/{tenantId}/principals/{principalId}/access-keys` would make a key's kind a property of the route it was created at, so each body would hold only the fields its kind uses and there would be no field combination to reject. A service credential would reach every bucket of the tenant rather than carrying a permission list, and the scoped-key path would leave Hilt entirely. The cost is a second set of create, list, and delete routes, a second vault path, a change to the management API contract the console already calls, and a migration that revokes and deletes every existing key before any member can be served. One route whose body takes one of two shapes leaves the console's existing calls working and keeps the parent RFC's authorization path in place for traffic that has no member actor. + +### An access model flag per tenant + +A tenant could carry an access-model flag, `scoped-keys` or `iam`, and Hilt could apply that model to every key the tenant holds. Each request would then read one model from the tenant row rather than branching on the key. A tenant cannot hold a service key and a member key at once under that rule, and a tenant holds both as soon as a member mints a key beside the console's. + +### A permission list on a principal-bound key + +A principal-bound key could carry its own permission list, similar to an AWS session policy, and Hilt could authorize against the intersection of that list and the bucket-policy result. This would let a member hold, for example, a read-only key and a full-access key for the same buckets without modeling them as separate principals. The trade-off is a second authorization document on every request: a 403 could come from either the key or the bucket policy, and Ingot's cached set would no longer represent the principal's effective actions directly. Rejecting key-level permission fields with 422 preserves a single source of truth for what a member may do. + +### Policy routes on the management API + +Policies could be read and written through `/tenants/{tenantId}/buckets/{bucketName}/policy` on the management API, behind the partner key. Only the console could then write a policy, and a bucket created with a generic S3 client would have no policy until the console wrote one. The S3 operations give the console and a customer's own tooling one path, and Ingot already routes bucket operations to Hilt the same way. + +### A default policy template per tenant + +Hilt could store one policy template per tenant and copy it onto each bucket at creation, so that a new bucket never lacks a policy. The console would then have to keep that template synchronized with the organization's roster, a second copy of the statements already written to each bucket, and the template could not name the member who created the bucket. Writing each bucket's policy with `PutBucketPolicy` sends the document once, at the point where it is needed. + +### A policy header on the create request + +The create request could carry the policy in a Forge-specific header, so that bucket creation and policy installation are one operation and no bucket outlives a failed policy write. AWS defines no such header, the document would be bound by Ingot's 8 KB request head, and `/s3/bucket/create` would validate and write policies beside `/s3/bucket/policy`. A bucket without a policy is reachable by service keys only, so the window between the create and the policy write grants nothing, and the console's durable retry closes it. + +### Filtering the bucket listing + +Hilt PR #48 proposed scoping `/s3/bucket/list` to the buckets reachable by a principal. The proposal was closed because it diverged from AWS behavior, where a listing may include bucket names the caller cannot open. The console filters its own list, and Ingot's behavior is unchanged. + +## Open questions + +1. What latency target does `GET /principals/{principalId}/access` carry? The console resolves it on every request it serves for a scoped member on an `iam` region. It is an index lookup at Hilt; the number is unmeasured. +2. A policy edit naming `"*"` rewrites the delegations of every key of every live principal of the tenant over that bucket, and sends one Swarf request whose size grows with the fan-out. A key holds up to seven delegations per bucket. There is no figure for principals per tenant or keys per principal on Forge. If the product is large, that write amplification is the cost to watch. +3. Limits on statements per policy and principals per statement. None are specified here, and `PutBucketPolicy` has no stated size limit (AWS caps a bucket policy at 20 KB). + +## Evaluation criteria + +- The time from a change's acknowledgement to Ingot's first refusal, measured against a warm key. This is the staleness bound the ADR asks Hilt to publish. +- Authorization latency for a service key is unchanged. +- No existing key stops working at any point in the rollout. +- The console's in-memory IAM fake and Hilt pass the same contract tests. +- A policy `PUT` costs one Swarf revocation and one replacement stored delegation for each affected Forge-command delegation on each key whose principal's effective actions changed. +- Swarf's service needs no changes. Ingot's firehose consumer adds only the revocation cache. + +## References + +- [Forge S3 tenant management](./2026-06-forge-s3-tenant-management.md), the parent RFC. +- [Bucket access by region: scoped keys on Aurora and FTH, IAM on Forge](https://github.com/fil-one/fil-one/blob/main/docs/architectural-decisions/2026-08-bucket-policies-m2.md), the ADR implemented here. +- [Organizations, membership, and roles](https://github.com/fil-one/fil-one/blob/main/docs/architectural-decisions/2026-08-organizations-roles-m1.md), for roles and the permission registry the console applies before calling Hilt. +- [Service Orchestrator Management API](https://github.com/fil-one/fil-one/blob/main/docs/service-orchestrator-integration/management-openapi.yaml), the contract the HTTP additions land in. +- [UCAN revocation](https://github.com/ucan-wg/revocation), for path witnesses. +- Hilt PR #48, the closed proposal for a scoped bucket listing. + +## Appendix + +### Schema + +The following schema changes extend the parent RFC. Types and constraints follow the existing migrations. + +```sql +CREATE TABLE principal ( + tenant_id TEXT NOT NULL REFERENCES tenant(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, -- principalId + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, -- set by removal; a PUT clears it + PRIMARY KEY (tenant_id, external_id) +); + +-- A key with no principal is the parent RFC's key, authorized from its own +-- permissions and buckets. A key with one is authorized from the bucket policies, +-- which its stored delegations materialize, so both are NULL. Existing rows have +-- no principal and need no backfill. +ALTER TABLE access_key + ALTER COLUMN permissions DROP NOT NULL, + ADD COLUMN principal_id TEXT, -- principalId + ADD FOREIGN KEY (tenant_id, principal_id) REFERENCES principal(tenant_id, external_id) ON DELETE RESTRICT, + ADD CONSTRAINT access_key_principal_unscoped + CHECK ((principal_id IS NULL AND permissions IS NOT NULL) + OR (principal_id IS NOT NULL AND permissions IS NULL AND buckets IS NULL)); + +-- A service key's name stays unique within the tenant; a principal-bound key's +-- name is unique within its principal. access_key_tenant_id_name_key is the name +-- Postgres generated for the parent RFC's UNIQUE (tenant_id, name). +ALTER TABLE access_key DROP CONSTRAINT access_key_tenant_id_name_key; +CREATE UNIQUE INDEX access_key_service_name_idx ON access_key (tenant_id, name) + WHERE principal_id IS NULL; +CREATE UNIQUE INDEX access_key_principal_name_idx ON access_key (tenant_id, principal_id, name) + WHERE principal_id IS NOT NULL; + +CREATE TABLE bucket_policy ( + bucket_id TEXT PRIMARY KEY REFERENCES bucket(id) ON DELETE CASCADE, + document JSONB NOT NULL, + etag TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Which principals a policy names; "*" statements are indexed with a NULL principal_id. +CREATE TABLE bucket_policy_principal ( + bucket_id TEXT NOT NULL REFERENCES bucket(id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL, + principal_id TEXT, + FOREIGN KEY (tenant_id, principal_id) REFERENCES principal(tenant_id, external_id) ON DELETE CASCADE +); +-- Postgres treats NULLs as distinct in a UNIQUE constraint, so the wildcard row gets its own index. +CREATE UNIQUE INDEX bucket_policy_principal_named_idx ON bucket_policy_principal (bucket_id, principal_id) WHERE principal_id IS NOT NULL; +CREATE UNIQUE INDEX bucket_policy_principal_wildcard_idx ON bucket_policy_principal (bucket_id) WHERE principal_id IS NULL; +CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (tenant_id, principal_id, bucket_id); +``` + +The `delegation` table is unchanged. For a principal-bound key, its rows represent policy-derived grants, one per bucket and Forge command. The vault keeps the parent RFC's layout: every key at `/tenant/{tenantDID}/access-key/{accessKeyDID}`, and no entry for a principal. + +### Example flows + +#### Migrate an existing tenant + +1. The tenant's keys keep working as service keys. Fil One changes nothing about them and keeps using the service key it holds. +2. Fil One calls `PUT /tenants/{tenantId}/principals/{principalId}` for every member. +3. Hilt stores each principal. +4. Fil One writes each bucket's policy with `PutBucketPolicy`, signed with its service key. From then on it checks each member's console traffic against their effective actions before signing it with that key. + +#### Create a bucket + +1. A member creates `photos` in the console. Fil One sends `PUT /photos` to Ingot signed with its service key. +2. Ingot invokes `/s3/bucket/create`; Hilt creates the bucket and Ingot adds it to its local database. +3. Fil One sends `PutBucketPolicy` for `photos` with `If-None-Match: *` and a document naming the member and the org's Owners and Admins. Ingot forwards it as `/s3/bucket/policy`; Hilt validates and stores the document and issues each named principal's keys their delegations over `photos`. +4. Fil One answers the member. Their next request on `photos` reaches Hilt and is authorized against the policy. + +#### Grant a member a bucket + +1. Fil One sends `GetBucketPolicy` for `photos` with its service key and keeps the `ETag`. +2. Fil One sends `PutBucketPolicy` with an added `Allow` statement and `If-Match`. +3. Hilt compares effective sets before and after for every named principal. The added member gained actions on a bucket their keys held nothing over, so Hilt stores the new delegations for each of the member's keys, publishes nothing, and commits the document. The response carries the new `ETag`. +4. Ingot holds no cached set for `photos` under the member's keys, so their next request on it reaches Hilt and is authorized against the new policy. The console lists `photos` for the member on their next request. + +#### Put an object with a principal-bound key + +1. A client sends `PUT /photos/cat.jpg` to Ingot, signed with the member's key. +2. Ingot finds no cached effective set for that key and bucket and invokes `/s3/request/authorize`. +3. Hilt verifies the signature, loads the key's principal, resolves `photos`, computes `effective(principal, photos)`, finds `s3:PutObject`, and signs key-to-Ingot delegations for the six write commands with the access key, expiring at the next UTC midnight. It returns them with their chains through the key's stored delegations. +4. Ingot invokes `/s3/bucket/info` for the bucket root, caches the chains and the effective set under the key, and serves the request. +5. Later requests from the key on `photos` are authorized locally: the action is in the cached set and a chain resolves for each command. + +#### Remove a member from a bucket + +1. Fil One sends `PutBucketPolicy` without the member's statement, with `If-Match`. +2. Hilt finds the member's effective set on the bucket shrank to nothing, publishes a revocation for each delegation the member's keys hold over `photos` in one Swarf request, deletes those delegations, and commits the document. An authorize request for `photos` that arrives meanwhile waits for the commit. +3. Swarf's firehose delivers the revocations. Ingot drops the per-key cache for every key whose cached proof chains contained one of the revoked delegations. +4. On the member's next request, Ingot no longer has cached authorization for the key and calls Hilt's `/s3/request/authorize`. On the removed bucket Hilt returns `UnknownBucket`, which Ingot renders as `NoSuchBucket`. + +#### Remove a member + +1. Fil One calls `DELETE /tenants/{t}/principals/{principalId}`. +2. Hilt publishes a revocation for every delegation of the member's keys in one Swarf request and strips the principal from every statement. It then locks the principal, deletes its keys, marks it removed, and commits. +3. Ingot drops each key's per-key cache when it consumes the revocation. The keys no longer resolve at Hilt. +4. If the member is re-invited, Fil One calls `PUT` for the same `principalId`. The tombstone clears and the principal starts with no keys and no statements.