From 9519f3bbf1f47863614dcf3cb51b0ce02ec4d661 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Wed, 9 Sep 2026 14:49:27 +0200 Subject: [PATCH 01/40] rfc: forge S3 tenant IAM Extends the tenant management RFC with principals, bucket policies, and principal-bound access keys, implementing the fil-one bucket policies ADR (IAM M2) on Hilt and Ingot. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 519 ++++++++++++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 rfcs/2026-09-forge-s3-tenant-iam.md 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..0769067 --- /dev/null +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -0,0 +1,519 @@ +# 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 access keys bound to principals, and Ingot enforces the actions those policies grant. Together they give the Fil One console what it needs to grant a member a subset 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. + +This model replaces the [parent RFC's](./2026-06-forge-s3-tenant-management.md) access keys. Its tenant, bucket, and delegation mechanics stay in force. + +## Motivation + +An access key on Hilt is a row of permissions and bucket names fixed at creation. Nothing can change what a key carries afterwards, and there is no object between the tenant and its keys that a change could attach to. The console therefore cannot give a member a subset of buckets today, and cannot widen or narrow that subset later without reissuing every key the member holds. + +On Forge the ADR settles three things: each member is a principal at the storage system, a bucket's own policy is the only thing that grants access, and a key belongs to a principal and carries whatever the policies give that principal, evaluated per request. + +## Goals + +1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `Allow` minus `Deny`, with an explicit `Deny` winning. +2. A policy edit changes what every key bound to a named principal may do, with no key reissued. +3. Every narrowing reaches the gateway's warm caches before Hilt acknowledges it, over the revocation path that already exists. +4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps one tenant-wide credential for traffic that has no member actor. +5. Hilt has one authorization model. Existing tenants move to it in place. + +## 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 revocation firehose. | + +### Terms + +- **Principal.** A member as Hilt knows them: `(tenant, userId)`. A principal carries no permissions and no role. It is an ed25519 `did:key` whose private key Hilt stores. +- **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. The policy is created with its bucket and destroyed with it. +- **Principal-bound key.** An S3 access key that authenticates a request and identifies a principal. It holds no authority of its own. +- **Service credential.** The console's tenant-wide key for traffic that has no member actor: tenant setup, bucket create and delete, and background workers. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. +- **Standing delegation.** The one stored delegation from a tenant to a principal. Rotating it is how a narrowing reaches the gateway. + +## Hilt - Tenant API + +Two kinds of credential exist on a tenant: one service credential, and any number of access keys, each bound to a principal. The parent RFC's `POST /tenants/{tenantId}/access-keys`, which created a key with its own permissions and bucket list, is removed. Key creation moves under principals; the list, get, and delete routes at the tenant level stay. + +### Tenant creation + +`PUT /tenants/{tenantId}` mints the service credential during setup and includes it in the 201 body. The idempotent 200 on a repeated call does not include it. + +A Forge network serves one region, and a tenant is unique to its region, so the external tenant id stays the organization's UUID as the contract defines it. Hilt's one-provider-per-tenant model holds unchanged. + +### Service credential + +The service credential is an ed25519 `did:key`, generated and stored like an access key, at the vault path `/tenant/{tenant}/service-credential`. Hilt MUST delegate every Forge command from the tenant to the credential as powerline delegations with no expiration, exactly as the parent RFC does for a key with no bucket list. Per request, Hilt authorizes the credential for every S3 action on every bucket of the tenant and re-delegates to the gateway as the parent RFC specifies. No policy is evaluated for it. + +The credential: + +- MUST be returned once, in the body of the call that minted it, as `serviceCredential: { accessKeyId, secretAccessKey }`. +- MAY be minted for a tenant that has none through `POST /tenants/{tenantId}/service-credential`, which returns it the same way and answers 409 when one exists. This is the migration path for existing tenants. +- MUST NOT appear in `GET /tenants/{tenantId}/access-keys`, MUST NOT count toward `accessKeyCount`, and MUST NOT be deletable through the access-key delete route. +- MUST NOT be bound to a principal. It is not named in any statement and reaches every bucket of the tenant. +- Has no expiry. Rotation is a later RFC. + +### Principals + +Routes, all under `/tenants/{tenantId}/principals` and authenticated with the partner key: + +| Method | Path | Purpose | +| ------ | ------------------------------------ | ------------------------------------------------------- | +| PUT | `/principals/{userId}` | Create the principal; 200 if it exists. Idempotent. | +| POST | `/principals` | Batch create: `{"userIds": [...]}`. Idempotent per id. | +| GET | `/principals` | List principals. | +| GET | `/principals/{userId}` | Principal detail. | +| DELETE | `/principals/{userId}` | Remove the principal (see [removal](#principal-removal)). 204 if already gone. | +| GET | `/principals/{userId}/policies` | Every bucket policy with a statement naming the principal or `*`. | +| GET | `/principals/{userId}/access` | The principal's effective actions per bucket. | +| POST | `/principals/{userId}/access-keys` | Issue a key bound to the principal. | +| GET | `/principals/{userId}/access-keys` | List the principal's keys. | + +`userId` is an opaque string supplied by the caller, unique within the tenant. The batch route exists for the console's provisioning sweep when a network moves to `iam`. + +A principal is represented as: + +```jsonc +{ + "userId": "8f2c...", + "did": "did:key:z6Mk...", + "createdAt": "2026-09-09T10:00:00Z" +} +``` + +On creation Hilt MUST: + +1. Generate an ed25519 key. Its DID is the principal's DID. Store the private key at `/tenant/{tenant}/principal/{did}`. +2. Issue and store the standing delegation, signed with the tenant key: + +```jsonc +{ + "iss": "did:plc:tenant", + "aud": "did:key:principal", + "sub": null, // powerline: every bucket the tenant owns, now or later + "cmd": "/", + "pol": [], + // no expiration +} +``` + +The standing delegation is a proof-chain hop and grants nothing at the request. Hilt holds the principal's private key and issues per-request delegations no wider than the bucket's policy allows. This is the position the parent RFC's powerline key delegations already occupy. + +The principal's effective actions are read through `GET /principals/{userId}/access`: + +```jsonc +{ + "buckets": [ + { "name": "photos", "actions": ["s3:GetObject", "s3:ListBucket"] }, + { "name": "backups", "actions": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"] } + ] +} +``` + +Buckets with an empty effective set are omitted. The read is computed from Hilt's store on every call and is consistent with Hilt's last write. It makes no network call. + +### Bucket policies + +A bucket has at most one policy, addressed by the bucket's name: + +| Method | Path | Purpose | +| ------ | --------------------------------------------- | --------------------------------------------------------- | +| GET | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Read the policy. Returns an `ETag`. | +| PUT | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Create or replace. Carries `If-Match` or `If-None-Match: *`. | +| DELETE | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Delete. Carries `If-Match`. | + +The document: + +```jsonc +{ + "statements": [ + { + "effect": "Allow", // "Allow" | "Deny" + "principals": ["8f2c...", "a91e..."], // userIds, or ["*"] for every principal of the tenant + "actions": ["s3:GetObject", "s3:ListBucket"] + } + ] +} +``` + +Hilt MUST reject with 422: + +- an action outside the [policy vocabulary](#action-vocabulary); in particular `s3:CreateBucket`, `s3:DeleteBucket`, and `s3:ListAllMyBuckets`, +- a `userId` that is not a principal of the tenant, +- an empty `statements` list (the caller deletes the policy instead), +- a bucket that belongs to another tenant, reported as 404. + +Hilt MUST accept a `Deny` naming `*`. The management API carries no actor; the console decides whether to write one. + +**Evaluation.** The effective actions of principal `p` on bucket `b` are + +``` +effective(p, b) = union(actions of Allow statements naming p or *) + \ union(actions of Deny statements naming p or *) +``` + +plus `s3:ListAllMyBuckets` (see [action vocabulary](#action-vocabulary)). A bucket with no policy has an empty effective set for every principal. The service credential is not a principal and is not evaluated against policies. + +**Compare-and-set.** `GET` returns a strong `ETag` computed over Hilt's canonical encoding of the stored document. `PUT` and `DELETE` MUST carry `If-Match` with that value; a mismatch is 412 and nothing is written. A `PUT` creating the first policy carries `If-None-Match: *` and is 412 if a policy already exists. Callers treat the ETag as opaque. A successful `PUT` returns the new `ETag`. + +**Storage.** Hilt stores the document with its bucket and maintains an index from principal to the buckets whose statements name it, so the two principal reads above cost an index lookup. Statements naming `*` are indexed under the tenant. The policy row is deleted with the bucket. + +### Bucket creation with a policy + +A bucket created over S3 with the service credential has no policy, so no principal reaches it until the console writes one. To close that window the Tenant API gains a bucket create: + +`POST /tenants/{tenantId}/buckets` + +```jsonc +{ + "name": "photos", + "policy": { "statements": [ /* ... */ ] } // optional +} +``` + +Hilt MUST create the bucket exactly as `/s3/bucket/create` does (ephemeral bucket key, root delegation to the tenant, space provisioned with Sprue, routing policy applied) and commit the bucket and its policy together after Sprue provisioning succeeds. A failure leaves no bucket. The response is 201 with the bucket's name, DID, creation time, and the policy's `ETag`. 409 if the name is taken. + +The gateway has not seen a bucket created here. Ingot registers it on its first `/s3/bucket/info` for the name (see [Ingot - S3 API](#ingot---s3-api)). + +### Principal-bound access keys + +`POST /tenants/{tenantId}/principals/{userId}/access-keys` + +```jsonc +{ + "name": "laptop", // unique per principal + "expiresAt": "2026-12-31T00:00:00Z" // optional +} +``` + +Hilt MUST generate and store the key exactly as the parent RFC specifies for an access key, and MUST record the principal on the key row. It MUST NOT issue any delegation to the key. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `userId` in place of `permissions` and `buckets`. 409 on a duplicate name for that principal. + +The console holds one such key per member for its own traffic, minted on the member's first request in the region, and signs that member's bucket listing, object listing, object reads and deletes, and presigned URLs with it. Hilt does not distinguish it from a key the member created: it is bound to the same principal, authorized against the same policies, and deleted with the principal. A presigned URL signed with it is authorized against the member's policies when it is redeemed, with the parent RFC's signature time bounds and the key's own expiry applying as for any request. + +`GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. The key list does not carry each key's access; the console reads a principal's access once through the principal route instead of once per key. + +`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` is unchanged. It MUST rotate the principal's standing delegation before deleting the key (see [narrowing](#narrowing-and-the-standing-delegation)), because the key holds no delegation of its own that a revocation could name. + +Access keys count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. + +### Narrowing and the standing delegation + +A narrowing is any change that removes an action a principal held on a bucket: a policy `PUT` or `DELETE` whose effective set for the principal shrinks, the deletion of one of the principal's keys, or the removal of the principal. Hilt MUST publish the revocations a narrowing requires before acknowledging it. + +The gateway caches proof chains until the next UTC midnight and drops a cached chain only when Swarf's firehose names a delegation in it. Every chain for a principal's keys contains the principal's standing delegation, so revoking that one delegation drops every warm chain for every key the principal holds. Hilt therefore rotates the standing delegation on each narrowing: + +1. Issue a new standing delegation for the principal and store it. +2. Publish a revocation of the previous one to Swarf, signed with the tenant key. The tenant issued it, so no witness path is needed. +3. Commit the change that caused the narrowing together with the new delegation, and delete the previous delegation row. + +If publishing fails, Hilt MUST return 500 and MUST NOT commit the change. A retry rotates again. The previous delegation stays valid until the revocation lands, so a failed narrowing leaves the principal with its old access and never with more. + +A policy write MUST compute, for each principal named in the old or new document (expanding `*` to every principal of the tenant), whether its effective set on the bucket lost any action, and rotate exactly those principals. A widening rotates nobody and publishes nothing. + +The staleness bound for a narrowing is the time from Hilt's acknowledgement to the gateway consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. + +### Principal removal + +`DELETE /tenants/{tenantId}/principals/{userId}` MUST, in order: + +1. Publish a revocation of the principal's standing delegation. +2. Remove the principal from every statement naming it, deleting statements left with no principal and policies left with no statement. +3. Delete the principal's keys: vault entries and rows. +4. Delete the standing delegation row, the principal's vault entry, and the principal row. + +A removed member who is later re-invited gets a new principal with a new DID. No statement survives to restore their old access. + +### Bucket deletion + +`/s3/bucket/delete` deletes the bucket's policy with the bucket. For an `iam` tenant there are no subject-scoped delegations to revoke: principals hold powerline delegations, and per-request delegations are not stored. Hilt publishes nothing. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. + +Deleting a tenant cascades over principals, policies, keys, and the service credential. As today, it publishes no revocations and deactivates the tenant's `did:plc`. + +## Hilt - UCAN API + +The commands and their argument types are unchanged. The result types gain one field, and the authorization steps gain a branch for `iam` tenants. + +### `/s3/request/authorize` + +For a request signed with the service credential, the parent RFC's steps apply with every action permitted on every bucket of the tenant. + +For a request signed with an access 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. +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`. +8. Issue one per-request delegation per Forge command mapped from the action, signed with the principal's private key: + +```jsonc +{ + "iss": "did:key:principal", + "aud": "did:web:gateway", // the invocation issuer + "sub": "did:key:bucket", + "cmd": "/content/retrieve", + // exp: next UTC midnight plus clock skew, capped at the key's expiry +} +``` + +The expiry rule is the parent RFC's. Per-request delegations are returned in the response container and are not stored. + +**Result.** `AuthorizeOK` gains an optional `principal` field, and `permissions` carries the key's effective actions on the addressed bucket: + +```ipldsch +type AuthorizeOK struct { + bucket optional String # bucket DID; absent for ListBuckets and CreateBucket + tenant String # tenant DID + principal optional String # principal DID; absent for the service credential + permissions { String: [String] } # access key DID → effective actions on `bucket` + keys { String: [VerificationKey] } + delegations { String: [Link] } +} +``` + +
+Go syntax + +```go +type AuthorizeOK struct { + Bucket *did.DID `cborgen:"bucket,omitempty"` + Tenant did.DID `cborgen:"tenant"` + Principal *did.DID `cborgen:"principal,omitempty"` + Permissions s3.PermissionSet `cborgen:"permissions"` + Keys s3.KeySet `cborgen:"keys"` + Delegations s3.ProofSet `cborgen:"delegations"` +} +``` +
+ +For `ListBuckets` the effective actions are `["s3:ListAllMyBuckets"]`. For the service credential `permissions` carries the full vocabulary. + +### `/s3/bucket/info` + +For an access key the chain runs from the bucket to the principal, since the key is not a hop: + +- root: bucket → tenant, `cmd: "/"` +- standing: tenant → principal, `sub: null`, `cmd: "/"` + +`InfoOK` gains the same optional `principal` field, and `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` + +Unchanged for the service credential. An access key receives `OperationNotPermitted` from both. Bucket deletion additionally removes the policy, as described above. + +### `/s3/bucket/list` + +Unchanged. The listing is the tenant's whole bucket set, as AWS lists names the caller cannot open. The console filters its own list against the principal's access. + +### Failures + +No new failure names. `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. Ingot's existing mapping renders them as `NoSuchBucket` (404) and `AccessDenied` (403). + +## Ingot - S3 API + +Ingot's request path is unchanged: authorize locally from cache when it can, and ask Hilt otherwise. + +**Ingot MUST enforce the effective action set.** Today Ingot discards the `permissions` field and authorizes locally by checking that its cache holds a proof chain for each Forge command the operation needs. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone therefore cannot enforce a `Deny` on one of them, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so a revocation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. + +**Cache invalidation is unchanged.** The firehose consumer drops every per-key store containing a revoked CID. The standing delegation is in every chain for a principal's keys, so a rotation clears them all. Ingot needs no new record type and no per-principal index. + +**Buckets created through the Tenant API.** Ingot registers a bucket in its own registry only inside its `CreateBucket` handler today. It MUST also register a bucket it first learns of from a successful `/s3/bucket/info`, with default versioning and object-lock state, so a bucket created by `POST /tenants/{tenantId}/buckets` is servable. + +Error mapping needs no change: `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. + +## Action vocabulary + +The policy vocabulary is Hilt's S3 permission set without the three bucket-level actions, plus the two bucket-configuration reads the console already offers on a key: + +| 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` | as `s3:PutObject` | | +| `s3:PutObjectLegalHold` | as `s3:PutObject` | | +| `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:GetBucketVersioning` | none | new; served by Ingot from its registry | +| `s3:GetBucketObjectLockConfiguration` | none | new; served by Ingot from its registry | + +Excluded from policies: `s3:CreateBucket` and `s3:DeleteBucket`, because a key holding them acts outside the policy that granted it, and `s3:ListAllMyBuckets`, which every principal holds. + +The two bucket-configuration reads are new to Hilt. Its operation classifier MUST recognize `GET /{bucket}?versioning` as `GetBucketVersioning` and `GET /{bucket}?object-lock` as `GetBucketObjectLockConfiguration`; today both classify as `ListBucket`. The management API's action enum, now used by policy statements, gains both, along with `s3:AbortMultipartUpload` and `s3:ListMultipartUploadParts`, which Hilt already accepts. + +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 + +Forge runs demo and dev environments only, so existing tenants are migrated in place. Per Forge network: + +1. Deploy Ingot with the effective-action check on every gateway serving the network. +2. Deploy Hilt. Its migration deletes every existing access key, publishing revocations for their delegations first, and drops the key's `permissions` and `buckets` columns. Tenants, buckets, and bucket root delegations are untouched. +3. The console calls `POST /tenants/{tenantId}/service-credential` for each existing tenant and stores the result where it keeps the tenant's console credential today. +4. The console writes principals for existing members through the batch route, and a policy per bucket naming the org's Owners and Admins. +5. The console flips the region's registry entry to `iam`. + +Between steps 2 and 5 no key on the network works. Demo and dev tolerate that window. + +## Alternatives considered + +### Allow statements materialized as stored delegations + +Hilt could write one tenant-to-principal delegation per bucket and Forge command that an Allow grants, and revoke exactly those on a narrowing. Stored authority would then equal granted authority, and revocation would name the precise delegation. It costs principals times buckets times commands rows, turns an Owner promotion into one delegation set per bucket, and still cannot express `Deny`, which would be enforced at Hilt and Ingot regardless. Rotating one standing delegation per principal produces the same cache effect from one row. + +### Recorded per-request delegations revoked on narrowing + +Hilt could persist each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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 gateway drops the whole per-key store on any revoked CID anyway, so the precision changes nothing at the cache. + +### A new Swarf record type + +An audience-scoped invalidation record naming the principal would let the standing delegation stay fixed. It changes Swarf and Ingot's consumer for a result the existing `/ucan/revoke` already produces once the standing delegation is one per principal. + +### The access key as a proof-chain hop + +Storing a principal-to-key delegation and re-delegating from the key per request would mirror today's mechanism and let a key deletion revoke one CID. It adds a hop to every chain and gives the key a delegation of its own, which the ADR's model says it does not have. + +### Policies evaluated at the gateway + +Ingot embeds versitygw, whose policy engine is disabled today by returning the admin role for 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 gateway, and leaves Hilt unable to issue delegations that match the decision. + +### Principals without key material + +A principal could be a plain row, with Hilt signing per-request delegations directly from the tenant key. The chain would be one hop shorter and the vault untouched. A principal that is a `did:key` can later sign its own invocations and is a real hop that a revocation can target, which is what the propagation design relies on. + +### Keeping the parent RFC's keys alongside + +An access model per tenant, `scoped-keys` or `iam`, would let existing tenants keep their keys until the console moved them. It leaves two authorization paths per request and two meanings for a key indefinitely. The tenants it would protect are demo and dev. + +### Filtering the bucket listing + +Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for access keys in Hilt PR #48 and closed as a divergence from AWS, where a listing shows names the caller cannot open. The console filters its own list, and the gateway's behavior is unchanged. + +## Open questions + +1. What latency target does `GET /principals/{userId}/access` carry? The console resolves it per request for its bucket list and activity feed. It is an index lookup at Hilt; the number is unmeasured. +2. A policy edit naming `*` rotates every principal of the tenant. There is no figure for principals per tenant on Forge. If it is large, the rotation is the cost to watch. +3. Limits on statements per policy and principals per statement. None are specified here. +4. Rotation of the service credential. The credential has no expiry and no rotate route. + +## Evaluation criteria + +- The time from a narrowing's acknowledgement to the gateway's first refusal, measured against a warm key. This is the staleness bound the ADR asks Hilt to publish. +- Authorization latency for the service credential is unchanged from today's tenant-wide key. +- The console's in-memory IAM fake and Hilt pass the same contract tests. +- A policy `PUT` costs one rotation per principal whose access shrank, and nothing else. + +## 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 + +Changes to the parent RFC's schema. Types and constraints follow the existing migrations. + +```sql +CREATE TABLE principal ( + id TEXT PRIMARY KEY, -- DID + tenant_id TEXT NOT NULL REFERENCES tenant(id) ON DELETE RESTRICT, + external_id TEXT NOT NULL, -- console userId + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, external_id) +); + +DELETE FROM access_key; -- revocations published by the migration first +ALTER TABLE access_key + DROP COLUMN permissions, + DROP COLUMN buckets, + ADD COLUMN principal_id TEXT REFERENCES principal(id) ON DELETE RESTRICT, + ADD COLUMN service BOOLEAN NOT NULL DEFAULT FALSE, + ADD CONSTRAINT access_key_kind CHECK (service <> (principal_id IS NOT NULL)); + +CREATE UNIQUE INDEX access_key_principal_name_idx ON access_key (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 as NULL principal. +CREATE TABLE bucket_policy_principal ( + bucket_id TEXT NOT NULL REFERENCES bucket(id) ON DELETE CASCADE, + principal_id TEXT REFERENCES principal(id) ON DELETE CASCADE, + UNIQUE (bucket_id, principal_id) +); +CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (principal_id, bucket_id); +``` + +The standing delegation lives in the existing `delegation` table with `audience` = principal DID and `subject` NULL. The vault gains `/tenant/{tenant}/principal/{did}` and `/tenant/{tenant}/service-credential`. + +### Example flows + +#### Migrate an existing tenant + +1. Hilt's migration has already deleted the tenant's keys and published their revocations. +2. Fil One calls `POST /tenants/{tenantId}/service-credential`. Hilt mints the credential, issues its powerline delegations, and returns it. +3. Fil One calls `POST /tenants/{tenantId}/principals` with every member's `userId`. +4. Hilt generates a key and a standing delegation per principal. +5. Fil One writes each bucket's policy. + +#### Grant a member a bucket + +1. Fil One reads `GET /tenants/{t}/buckets/photos/policy` and its `ETag`. +2. Fil One writes the document with an added `Allow` statement, sending `If-Match`. +3. Hilt compares effective sets before and after for every named principal. Nobody lost an action, so nothing is rotated or published. Hilt stores the document and returns the new `ETag`. +4. The member's next request on `photos` misses the gateway cache, reaches Hilt, and is authorized against the new policy. + +#### Put an object with an access 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 issues principal-to-gateway delegations for the six write commands, expiring at the next UTC midnight. +4. Ingot invokes `/s3/bucket/info` for the root and standing delegations, 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 writes the policy without the member's statement, with `If-Match`. +2. Hilt finds the member's effective set on the bucket shrank, issues a new standing delegation for the principal, publishes a revocation of the old one to Swarf, commits the document and the new delegation, and deletes the old delegation row. +3. Swarf's firehose delivers the revoked CID. Ingot drops every cached store containing it, which is every store for the member's keys. +4. The member's next request on any bucket reaches Hilt. On the removed bucket it receives `UnknownBucket`, which Ingot renders as `NoSuchBucket`. + +#### Remove a member + +1. Fil One calls `DELETE /tenants/{t}/principals/{userId}`. +2. Hilt publishes a revocation of the standing delegation, strips the principal from every statement, deletes its keys, and deletes the principal. +3. Ingot drops the member's cached stores on the firehose record. Their keys no longer resolve at Hilt. From e71c82f757bcf86c6e4943801570116ee406ba18 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Wed, 9 Sep 2026 14:57:40 +0200 Subject: [PATCH 02/40] rfc: drop batch principal creation The only caller was the one-off migration sweep, which loops over the idempotent single-principal PUT instead. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 0769067..3826d36 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -75,7 +75,6 @@ Routes, all under `/tenants/{tenantId}/principals` and authenticated with the pa | Method | Path | Purpose | | ------ | ------------------------------------ | ------------------------------------------------------- | | PUT | `/principals/{userId}` | Create the principal; 200 if it exists. Idempotent. | -| POST | `/principals` | Batch create: `{"userIds": [...]}`. Idempotent per id. | | GET | `/principals` | List principals. | | GET | `/principals/{userId}` | Principal detail. | | DELETE | `/principals/{userId}` | Remove the principal (see [removal](#principal-removal)). 204 if already gone. | @@ -84,7 +83,7 @@ Routes, all under `/tenants/{tenantId}/principals` and authenticated with the pa | POST | `/principals/{userId}/access-keys` | Issue a key bound to the principal. | | GET | `/principals/{userId}/access-keys` | List the principal's keys. | -`userId` is an opaque string supplied by the caller, unique within the tenant. The batch route exists for the console's provisioning sweep when a network moves to `iam`. +`userId` is an opaque string supplied by the caller, unique within the tenant. A principal is represented as: @@ -374,7 +373,7 @@ Forge runs demo and dev environments only, so existing tenants are migrated in p 1. Deploy Ingot with the effective-action check on every gateway serving the network. 2. Deploy Hilt. Its migration deletes every existing access key, publishing revocations for their delegations first, and drops the key's `permissions` and `buckets` columns. Tenants, buckets, and bucket root delegations are untouched. 3. The console calls `POST /tenants/{tenantId}/service-credential` for each existing tenant and stores the result where it keeps the tenant's console credential today. -4. The console writes principals for existing members through the batch route, and a policy per bucket naming the org's Owners and Admins. +4. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{userId}`, then writes a policy per bucket naming the org's Owners and Admins. 5. The console flips the region's registry entry to `iam`. Between steps 2 and 5 no key on the network works. Demo and dev tolerate that window. @@ -486,7 +485,7 @@ The standing delegation lives in the existing `delegation` table with `audience` 1. Hilt's migration has already deleted the tenant's keys and published their revocations. 2. Fil One calls `POST /tenants/{tenantId}/service-credential`. Hilt mints the credential, issues its powerline delegations, and returns it. -3. Fil One calls `POST /tenants/{tenantId}/principals` with every member's `userId`. +3. Fil One calls `PUT /tenants/{tenantId}/principals/{userId}` for every member. 4. Hilt generates a key and a standing delegation per principal. 5. Fil One writes each bucket's policy. From bfce0390e5bc207dd9644b29c5d0360b043e680e Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Wed, 9 Sep 2026 15:55:35 +0200 Subject: [PATCH 03/40] rfc: key-less principals and Swarf principal invalidation A principal is a (tenant, userId) row with no key material; per-request delegations are signed with the tenant key. Narrowings reach the gateway through a new Swarf /principal/invalidate command and firehose event, published by Hilt's service identity from a configured publisher list. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 193 ++++++++++++++++------------ 1 file changed, 110 insertions(+), 83 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 3826d36..9febd16 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -24,7 +24,7 @@ On Forge the ADR settles three things: each member is a principal at the storage 1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `Allow` minus `Deny`, with an explicit `Deny` winning. 2. A policy edit changes what every key bound to a named principal may do, with no key reissued. -3. Every narrowing reaches the gateway's warm caches before Hilt acknowledges it, over the revocation path that already exists. +3. Every narrowing reaches the gateway's warm caches before Hilt acknowledges it, through a Swarf record that Ingot's firehose consumer acts on. 4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps one tenant-wide credential for traffic that has no member actor. 5. Hilt has one authorization model. Existing tenants move to it in place. @@ -36,15 +36,15 @@ The parent RFC's roles apply. Swarf is added: | Name | Description | | ----- | ---------------------------------------------------------------------------------------------------------- | -| Swarf | The Forge revocation service. Hilt publishes revocations to it; Ingot consumes its revocation firehose. | +| Swarf | The Forge revocation service. Hilt publishes revocations and principal invalidations to it; Ingot consumes its firehose. | ### Terms -- **Principal.** A member as Hilt knows them: `(tenant, userId)`. A principal carries no permissions and no role. It is an ed25519 `did:key` whose private key Hilt stores. +- **Principal.** A member as Hilt knows them: `(tenant, userId)` and nothing more. A principal carries no permissions, no role, and no key material. - **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. The policy is created with its bucket and destroyed with it. - **Principal-bound key.** An S3 access key that authenticates a request and identifies a principal. It holds no authority of its own. - **Service credential.** The console's tenant-wide key for traffic that has no member actor: tenant setup, bucket create and delete, and background workers. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. -- **Standing delegation.** The one stored delegation from a tenant to a principal. Rotating it is how a narrowing reaches the gateway. +- **Principal invalidation.** The Swarf record that tells every gateway to forget the proofs it cached for a principal's keys. Publishing it is how a narrowing reaches the gateway. ## Hilt - Tenant API @@ -90,28 +90,11 @@ A principal is represented as: ```jsonc { "userId": "8f2c...", - "did": "did:key:z6Mk...", "createdAt": "2026-09-09T10:00:00Z" } ``` -On creation Hilt MUST: - -1. Generate an ed25519 key. Its DID is the principal's DID. Store the private key at `/tenant/{tenant}/principal/{did}`. -2. Issue and store the standing delegation, signed with the tenant key: - -```jsonc -{ - "iss": "did:plc:tenant", - "aud": "did:key:principal", - "sub": null, // powerline: every bucket the tenant owns, now or later - "cmd": "/", - "pol": [], - // no expiration -} -``` - -The standing delegation is a proof-chain hop and grants nothing at the request. Hilt holds the principal's private key and issues per-request delegations no wider than the bucket's policy allows. This is the position the parent RFC's powerline key delegations already occupy. +Creating a principal stores the row and nothing else. A principal has no DID, no vault entry, and no delegation. It appears in no UCAN: Hilt evaluates the bucket policy for the principal and signs the per-request delegation with the tenant key, so the proof chain runs from the bucket to the tenant to the gateway. The principal's effective actions are read through `GET /principals/{userId}/access`: @@ -206,46 +189,42 @@ The console holds one such key per member for its own traffic, minted on the mem `GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. The key list does not carry each key's access; the console reads a principal's access once through the principal route instead of once per key. -`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` is unchanged. It MUST rotate the principal's standing delegation before deleting the key (see [narrowing](#narrowing-and-the-standing-delegation)), because the key holds no delegation of its own that a revocation could name. +`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` is unchanged. It MUST publish a principal invalidation before deleting the key (see [narrowing](#narrowing-and-principal-invalidation)), because the key holds no delegation of its own that a revocation could name. Access keys count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. -### Narrowing and the standing delegation - -A narrowing is any change that removes an action a principal held on a bucket: a policy `PUT` or `DELETE` whose effective set for the principal shrinks, the deletion of one of the principal's keys, or the removal of the principal. Hilt MUST publish the revocations a narrowing requires before acknowledging it. +### Narrowing and principal invalidation -The gateway caches proof chains until the next UTC midnight and drops a cached chain only when Swarf's firehose names a delegation in it. Every chain for a principal's keys contains the principal's standing delegation, so revoking that one delegation drops every warm chain for every key the principal holds. Hilt therefore rotates the standing delegation on each narrowing: +A narrowing is any change that removes an action a principal held on a bucket: a policy `PUT` or `DELETE` whose effective set for the principal shrinks, the deletion of one of the principal's keys, or the removal of the principal. Hilt MUST publish a principal invalidation for each affected principal before acknowledging the change. -1. Issue a new standing delegation for the principal and store it. -2. Publish a revocation of the previous one to Swarf, signed with the tenant key. The tenant issued it, so no witness path is needed. -3. Commit the change that caused the narrowing together with the new delegation, and delete the previous delegation row. +The gateway caches proof chains and effective action sets per access key until the next UTC midnight. Nothing in those chains identifies the principal, so no delegation revocation can clear them. The invalidation record names the principal directly (see [Swarf - principal invalidation](#swarf---principal-invalidation)), and the gateway drops every store it holds for that principal's keys. -If publishing fails, Hilt MUST return 500 and MUST NOT commit the change. A retry rotates again. The previous delegation stays valid until the revocation lands, so a failed narrowing leaves the principal with its old access and never with more. +If publishing fails, Hilt MUST return 500 and MUST NOT commit the change. A retry publishes again. A failed narrowing leaves the principal with its old access and never with more. -A policy write MUST compute, for each principal named in the old or new document (expanding `*` to every principal of the tenant), whether its effective set on the bucket lost any action, and rotate exactly those principals. A widening rotates nobody and publishes nothing. +A policy write MUST compute, for each principal named in the old or new document (expanding `*` to every principal of the tenant), whether its effective set on the bucket lost any action, and publish one invalidation per such principal. A widening publishes nothing. Deleting a key publishes one invalidation for the key's principal; the principal's other keys lose their cached proofs too and refill on their next request. -The staleness bound for a narrowing is the time from Hilt's acknowledgement to the gateway consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. +The staleness bound for a narrowing is the time from Hilt's acknowledgement to the gateway consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The record clears gateway caches and nothing else: a per-request delegation the gateway already holds in flight stays valid at Sprue and Piri until its expiry, because those services check revocation by delegation CID and the record names none. ### Principal removal `DELETE /tenants/{tenantId}/principals/{userId}` MUST, in order: -1. Publish a revocation of the principal's standing delegation. +1. Publish a principal invalidation. 2. Remove the principal from every statement naming it, deleting statements left with no principal and policies left with no statement. 3. Delete the principal's keys: vault entries and rows. -4. Delete the standing delegation row, the principal's vault entry, and the principal row. +4. Delete the principal row. -A removed member who is later re-invited gets a new principal with a new DID. No statement survives to restore their old access. +A removed member who is later re-invited gets a new principal row. No statement survives to restore their old access. ### Bucket deletion -`/s3/bucket/delete` deletes the bucket's policy with the bucket. For an `iam` tenant there are no subject-scoped delegations to revoke: principals hold powerline delegations, and per-request delegations are not stored. Hilt publishes nothing. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. +`/s3/bucket/delete` deletes the bucket's policy with the bucket. There is nothing to revoke: principals hold no delegations, and per-request delegations are not stored. Hilt publishes nothing. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. Deleting a tenant cascades over principals, policies, keys, and the service credential. As today, it publishes no revocations and deactivates the tenant's `did:plc`. ## Hilt - UCAN API -The commands and their argument types are unchanged. The result types gain one field, and the authorization steps gain a branch for `iam` tenants. +The commands and their argument types are unchanged. The result types gain one field, and the authorization steps gain a branch for access keys. ### `/s3/request/authorize` @@ -260,11 +239,11 @@ For a request signed with an access key, Hilt performs the parent RFC's steps th 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`. -8. Issue one per-request delegation per Forge command mapped from the action, signed with the principal's private key: +8. Issue one per-request delegation per Forge command mapped from the action, signed with the tenant key: ```jsonc { - "iss": "did:key:principal", + "iss": "did:plc:tenant", "aud": "did:web:gateway", // the invocation issuer "sub": "did:key:bucket", "cmd": "/content/retrieve", @@ -272,7 +251,7 @@ For a request signed with an access key, Hilt performs the parent RFC's steps th } ``` -The expiry rule is the parent RFC's. Per-request delegations are returned in the response container and are not stored. +The expiry rule is the parent RFC's. Per-request delegations are returned in the response container and are not stored. Hilt already loads the tenant key to provision buckets, so signing with it adds no vault read the request path does not have today. **Result.** `AuthorizeOK` gains an optional `principal` field, and `permissions` carries the key's effective actions on the addressed bucket: @@ -280,7 +259,7 @@ The expiry rule is the parent RFC's. Per-request delegations are returned in the type AuthorizeOK struct { bucket optional String # bucket DID; absent for ListBuckets and CreateBucket tenant String # tenant DID - principal optional String # principal DID; absent for the service credential + principal optional String # principal userId; absent for the service credential permissions { String: [String] } # access key DID → effective actions on `bucket` keys { String: [VerificationKey] } delegations { String: [Link] } @@ -294,7 +273,7 @@ type AuthorizeOK struct { type AuthorizeOK struct { Bucket *did.DID `cborgen:"bucket,omitempty"` Tenant did.DID `cborgen:"tenant"` - Principal *did.DID `cborgen:"principal,omitempty"` + Principal *string `cborgen:"principal,omitempty"` Permissions s3.PermissionSet `cborgen:"permissions"` Keys s3.KeySet `cborgen:"keys"` Delegations s3.ProofSet `cborgen:"delegations"` @@ -302,14 +281,11 @@ type AuthorizeOK struct { ``` -For `ListBuckets` the effective actions are `["s3:ListAllMyBuckets"]`. For the service credential `permissions` carries the full vocabulary. +For `ListBuckets` the effective actions are `["s3:ListAllMyBuckets"]`. For the service credential `permissions` carries the full vocabulary. `tenant` and `principal` together identify the principal, and are what the gateway indexes its caches by. ### `/s3/bucket/info` -For an access key the chain runs from the bucket to the principal, since the key is not a hop: - -- root: bucket → tenant, `cmd: "/"` -- standing: tenant → principal, `sub: null`, `cmd: "/"` +For an access key the chain is the bucket root alone, bucket → tenant with `cmd: "/"`. Neither the principal nor the key is a hop. `InfoOK` gains the same optional `principal` field, and `permissions` carries the key's effective actions on the bucket. A key whose principal cannot reach the bucket receives `UnknownBucket`. @@ -325,13 +301,65 @@ Unchanged. The listing is the tenant's whole bucket set, as AWS lists names the No new failure names. `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. Ingot's existing mapping renders them as `NoSuchBucket` (404) and `AccessDenied` (403). +## Swarf - principal invalidation + +Swarf gains one command and one firehose event. Both carry a principal identity and no delegation. + +### `/principal/invalidate` + +* Issuer: Hilt, as its service identity (`did:web:auth.`) +* Audience: Swarf +* Subject: Swarf + +Records that every proof a gateway cached for the principal's keys is void. + +#### Arguments + +```ipldsch +type InvalidateArguments struct { + tenant String # tenant DID + principal String # userId, unique within the tenant +} +``` + +
+Go syntax + +```go +type InvalidateArguments struct { + Tenant did.DID `cborgen:"tenant"` + Principal string `cborgen:"principal"` +} +``` +
+ +#### Result + +A unit result (`{}`). + +#### Authorization + +There is no delegation to the principal, so no witness path can prove the invoker's authority over it. Swarf MUST accept the command only from issuers in a configured publisher list, and MUST refuse it from anyone else. The list holds the Hilt service identities of the networks Swarf serves. + +### Firehose + +`GET /revocations/:from` emits a second event kind alongside `revocation`: + +``` +id: bafyreif5fzax7oygfafacvxq2ndhtkshz2av5m42hqeixea7giirdxe5dm +event: principal +data: {"tenant":"did:plc:tenant","principal":"8f2c...","cause":{"/":"bafyreif5fz..."},"recorded_at":"2026-09-09T10:00:00Z"} +``` + +The cursor, the inclusive resume rule, and deduplication by `cause` are the same as for revocation events. `GET /revocation/:cid` is unaffected: the record revokes no CID, and a verifier that checks proofs by CID never sees it. + ## Ingot - S3 API Ingot's request path is unchanged: authorize locally from cache when it can, and ask Hilt otherwise. -**Ingot MUST enforce the effective action set.** Today Ingot discards the `permissions` field and authorizes locally by checking that its cache holds a proof chain for each Forge command the operation needs. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone therefore cannot enforce a `Deny` on one of them, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so a revocation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. +**Ingot MUST enforce the effective action set.** Today Ingot discards the `permissions` field and authorizes locally by checking that its cache holds a proof chain for each Forge command the operation needs. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone therefore cannot enforce a `Deny` on one of them, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so an invalidation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. -**Cache invalidation is unchanged.** The firehose consumer drops every per-key store containing a revoked CID. The standing delegation is in every chain for a principal's keys, so a rotation clears them all. Ingot needs no new record type and no per-principal index. +**The firehose consumer gains a branch.** It drops every per-key store containing a revoked CID today. On a `principal` event it MUST drop every store for access keys whose authorize result carried the event's tenant and principal, which requires Ingot to index its per-key stores by that pair. Dropping the proof stores is enough; the derived signing keys may stay, since a request with a signing key and no chain goes to Hilt. Like the existing revoker, the handler is idempotent and an event matching nothing is a no-op. **Buckets created through the Tenant API.** Ingot registers a bucket in its own registry only inside its `CreateBucket` handler today. It MUST also register a bucket it first learns of from a successful `/s3/bucket/info`, with default versioning and object-lock state, so a bucket created by `POST /tenants/{tenantId}/buckets` is servable. @@ -370,7 +398,7 @@ Retention and legal-hold writes pass through the API like any other action. The Forge runs demo and dev environments only, so existing tenants are migrated in place. Per Forge network: -1. Deploy Ingot with the effective-action check on every gateway serving the network. +1. Deploy Swarf with the publisher list, and Ingot with the effective-action check and the `principal` event branch, on every gateway serving the network. 2. Deploy Hilt. Its migration deletes every existing access key, publishing revocations for their delegations first, and drops the key's `permissions` and `buckets` columns. Tenants, buckets, and bucket root delegations are untouched. 3. The console calls `POST /tenants/{tenantId}/service-credential` for each existing tenant and stores the result where it keeps the tenant's console credential today. 4. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{userId}`, then writes a policy per bucket naming the org's Owners and Admins. @@ -382,28 +410,24 @@ Between steps 2 and 5 no key on the network works. Demo and dev tolerate that wi ### Allow statements materialized as stored delegations -Hilt could write one tenant-to-principal delegation per bucket and Forge command that an Allow grants, and revoke exactly those on a narrowing. Stored authority would then equal granted authority, and revocation would name the precise delegation. It costs principals times buckets times commands rows, turns an Owner promotion into one delegation set per bucket, and still cannot express `Deny`, which would be enforced at Hilt and Ingot regardless. Rotating one standing delegation per principal produces the same cache effect from one row. +Hilt could write one tenant-to-principal delegation per bucket and Forge command that an Allow grants, and revoke exactly those on a narrowing. Stored authority would then equal granted authority, and revocation would name the precise delegation. It costs principals times buckets times commands rows, turns an Owner promotion into one delegation set per bucket, and still cannot express `Deny`, which would be enforced at Hilt and Ingot regardless. One invalidation record per principal produces the same cache effect from no rows. ### Recorded per-request delegations revoked on narrowing Hilt could persist each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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 gateway drops the whole per-key store on any revoked CID anyway, so the precision changes nothing at the cache. -### A new Swarf record type +### A did:key principal with a standing delegation -An audience-scoped invalidation record naming the principal would let the standing delegation stay fixed. It changes Swarf and Ingot's consumer for a result the existing `/ucan/revoke` already produces once the standing delegation is one per principal. +Each principal could be an ed25519 key Hilt stores, holding one powerline delegation from the tenant that sits in every chain for the principal's keys. A narrowing would then rotate that delegation and revoke the old one by CID through Swarf's existing command, so Swarf and Ingot's consumer would change nothing and every downstream verifier would honor the revocation. A did:key principal could also 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 rotation on every narrowing. The invalidation record trades all of that for a signal only gateways act on. ### The access key as a proof-chain hop -Storing a principal-to-key delegation and re-delegating from the key per request would mirror today's mechanism and let a key deletion revoke one CID. It adds a hop to every chain and gives the key a delegation of its own, which the ADR's model says it does not have. +Storing a tenant-to-key delegation and re-delegating from the key per request would mirror today's mechanism, let a key deletion revoke one CID, and clear the gateway through the existing revocation path. It adds a hop to every chain and gives the key a delegation of its own, which the ADR's model says it does not have, and a policy edit would still have nothing to revoke. ### Policies evaluated at the gateway Ingot embeds versitygw, whose policy engine is disabled today by returning the admin role for 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 gateway, and leaves Hilt unable to issue delegations that match the decision. -### Principals without key material - -A principal could be a plain row, with Hilt signing per-request delegations directly from the tenant key. The chain would be one hop shorter and the vault untouched. A principal that is a `did:key` can later sign its own invocations and is a real hop that a revocation can target, which is what the propagation design relies on. - ### Keeping the parent RFC's keys alongside An access model per tenant, `scoped-keys` or `iam`, would let existing tenants keep their keys until the console moved them. It leaves two authorization paths per request and two meanings for a key indefinitely. The tenants it would protect are demo and dev. @@ -415,16 +439,17 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac ## Open questions 1. What latency target does `GET /principals/{userId}/access` carry? The console resolves it per request for its bucket list and activity feed. It is an index lookup at Hilt; the number is unmeasured. -2. A policy edit naming `*` rotates every principal of the tenant. There is no figure for principals per tenant on Forge. If it is large, the rotation is the cost to watch. -3. Limits on statements per policy and principals per statement. None are specified here. -4. Rotation of the service credential. The credential has no expiry and no rotate route. +2. A policy edit naming `*` publishes one invalidation per principal of the tenant. There is no figure for principals per tenant on Forge. If it is large, that fan-out is the cost to watch. +3. Swarf trusts the publisher list and checks no proof. Whether Swarf should verify that a tenant owns the named principal, for example by consulting Hilt, is open. +4. Limits on statements per policy and principals per statement. None are specified here. +5. Rotation of the service credential. The credential has no expiry and no rotate route. ## Evaluation criteria - The time from a narrowing's acknowledgement to the gateway's first refusal, measured against a warm key. This is the staleness bound the ADR asks Hilt to publish. - Authorization latency for the service credential is unchanged from today's tenant-wide key. - The console's in-memory IAM fake and Hilt pass the same contract tests. -- A policy `PUT` costs one rotation per principal whose access shrank, and nothing else. +- A policy `PUT` costs one Swarf record per principal whose access shrank, and no delegation writes. ## References @@ -443,23 +468,23 @@ Changes to the parent RFC's schema. Types and constraints follow the existing mi ```sql CREATE TABLE principal ( - id TEXT PRIMARY KEY, -- DID tenant_id TEXT NOT NULL REFERENCES tenant(id) ON DELETE RESTRICT, external_id TEXT NOT NULL, -- console userId created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (tenant_id, external_id) + PRIMARY KEY (tenant_id, external_id) ); DELETE FROM access_key; -- revocations published by the migration first ALTER TABLE access_key DROP COLUMN permissions, DROP COLUMN buckets, - ADD COLUMN principal_id TEXT REFERENCES principal(id) ON DELETE RESTRICT, - ADD COLUMN service BOOLEAN NOT NULL DEFAULT FALSE, - ADD CONSTRAINT access_key_kind CHECK (service <> (principal_id IS NOT NULL)); + ADD COLUMN principal TEXT, -- console userId + ADD COLUMN service BOOLEAN NOT NULL DEFAULT FALSE, + ADD FOREIGN KEY (tenant_id, principal) REFERENCES principal(tenant_id, external_id) ON DELETE RESTRICT, + ADD CONSTRAINT access_key_kind CHECK (service <> (principal IS NOT NULL)); -CREATE UNIQUE INDEX access_key_principal_name_idx ON access_key (principal_id, name) - WHERE principal_id IS NOT NULL; +CREATE UNIQUE INDEX access_key_principal_name_idx ON access_key (tenant_id, principal, name) + WHERE principal IS NOT NULL; CREATE TABLE bucket_policy ( bucket_id TEXT PRIMARY KEY REFERENCES bucket(id) ON DELETE CASCADE, @@ -470,14 +495,16 @@ CREATE TABLE bucket_policy ( -- Which principals a policy names; '*' statements are indexed as NULL principal. CREATE TABLE bucket_policy_principal ( - bucket_id TEXT NOT NULL REFERENCES bucket(id) ON DELETE CASCADE, - principal_id TEXT REFERENCES principal(id) ON DELETE CASCADE, - UNIQUE (bucket_id, principal_id) + bucket_id TEXT NOT NULL REFERENCES bucket(id) ON DELETE CASCADE, + tenant_id TEXT NOT NULL, + principal TEXT, + FOREIGN KEY (tenant_id, principal) REFERENCES principal(tenant_id, external_id) ON DELETE CASCADE, + UNIQUE (bucket_id, principal) ); -CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (principal_id, bucket_id); +CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (tenant_id, principal, bucket_id); ``` -The standing delegation lives in the existing `delegation` table with `audience` = principal DID and `subject` NULL. The vault gains `/tenant/{tenant}/principal/{did}` and `/tenant/{tenant}/service-credential`. +The `delegation` table is unchanged. The vault gains `/tenant/{tenant}/service-credential` and nothing for principals. ### Example flows @@ -486,33 +513,33 @@ The standing delegation lives in the existing `delegation` table with `audience` 1. Hilt's migration has already deleted the tenant's keys and published their revocations. 2. Fil One calls `POST /tenants/{tenantId}/service-credential`. Hilt mints the credential, issues its powerline delegations, and returns it. 3. Fil One calls `PUT /tenants/{tenantId}/principals/{userId}` for every member. -4. Hilt generates a key and a standing delegation per principal. +4. Hilt stores each principal. 5. Fil One writes each bucket's policy. #### Grant a member a bucket 1. Fil One reads `GET /tenants/{t}/buckets/photos/policy` and its `ETag`. 2. Fil One writes the document with an added `Allow` statement, sending `If-Match`. -3. Hilt compares effective sets before and after for every named principal. Nobody lost an action, so nothing is rotated or published. Hilt stores the document and returns the new `ETag`. +3. Hilt compares effective sets before and after for every named principal. Nobody lost an action, so nothing is published. Hilt stores the document and returns the new `ETag`. 4. The member's next request on `photos` misses the gateway cache, reaches Hilt, and is authorized against the new policy. #### Put an object with an access 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 issues principal-to-gateway delegations for the six write commands, expiring at the next UTC midnight. -4. Ingot invokes `/s3/bucket/info` for the root and standing delegations, caches the chains and the effective set under the key, and serves the request. +3. Hilt verifies the signature, loads the key's principal, resolves `photos`, computes `effective(principal, photos)`, finds `s3:PutObject`, and signs tenant-to-gateway delegations for the six write commands with the tenant key, expiring at the next UTC midnight. +4. Ingot invokes `/s3/bucket/info` for the bucket root, caches the chains and the effective set under the key, indexed by tenant and principal, 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 writes the policy without the member's statement, with `If-Match`. -2. Hilt finds the member's effective set on the bucket shrank, issues a new standing delegation for the principal, publishes a revocation of the old one to Swarf, commits the document and the new delegation, and deletes the old delegation row. -3. Swarf's firehose delivers the revoked CID. Ingot drops every cached store containing it, which is every store for the member's keys. +2. Hilt finds the member's effective set on the bucket shrank, invokes `/principal/invalidate` on Swarf for the member, and commits the document. +3. Swarf's firehose delivers the `principal` event. Ingot drops every cached store for the member's keys. 4. The member's next request on any bucket reaches Hilt. On the removed bucket it receives `UnknownBucket`, which Ingot renders as `NoSuchBucket`. #### Remove a member 1. Fil One calls `DELETE /tenants/{t}/principals/{userId}`. -2. Hilt publishes a revocation of the standing delegation, strips the principal from every statement, deletes its keys, and deletes the principal. -3. Ingot drops the member's cached stores on the firehose record. Their keys no longer resolve at Hilt. +2. Hilt invokes `/principal/invalidate` on Swarf, strips the principal from every statement, deletes its keys, and deletes the principal row. +3. Ingot drops the member's cached stores on the `principal` event. Their keys no longer resolve at Hilt. From a53db0203e80e3e46b542d15618f6f7187317add Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Thu, 10 Sep 2026 15:52:44 +0200 Subject: [PATCH 04/40] rfc: several service credentials, invalidation on any policy change, locked publish Service credentials become a list per tenant with mint, list, and delete routes, so a lost mint response or a rotation is a second mint and a delete. A policy write publishes an invalidation for every principal whose effective set changed in either direction, so a widening reaches a warm gateway cache. The write publishes inside its transaction with the policy, key, or principal row locked, and the authorize path reads those rows with a shared lock, so a gateway cannot refill its cache from the old policy between publish and commit. The Tenant API bucket create is dropped: a bucket without a policy is reachable by service credentials only, and the console writes the policy right after creating the bucket over S3. With it goes the Ingot requirement to register buckets learned from bucket-info. Bucket deletion states the one-gateway assumption it relies on. ListAllMyBuckets leaves the per-bucket effective set, the cross-tenant bucket case is a 404 on its own, the two bucket-configuration reads are authorized at Hilt on every request, and the wildcard index row gets a partial unique index. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 119 ++++++++++++++-------------- 1 file changed, 59 insertions(+), 60 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 9febd16..910b912 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -24,8 +24,8 @@ On Forge the ADR settles three things: each member is a principal at the storage 1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `Allow` minus `Deny`, with an explicit `Deny` winning. 2. A policy edit changes what every key bound to a named principal may do, with no key reissued. -3. Every narrowing reaches the gateway's warm caches before Hilt acknowledges it, through a Swarf record that Ingot's firehose consumer acts on. -4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps one tenant-wide credential for traffic that has no member actor. +3. Every change to a principal's access is published to Swarf before Hilt acknowledges it, and Ingot's firehose consumer clears the gateway's warm caches within firehose latency. +4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps a tenant-wide service credential for traffic that has no member actor. 5. Hilt has one authorization model. Existing tenants move to it in place. ## Concepts @@ -43,30 +43,39 @@ The parent RFC's roles apply. Swarf is added: - **Principal.** A member as Hilt knows them: `(tenant, userId)` and nothing more. A principal carries no permissions, no role, and no key material. - **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. The policy is created with its bucket and destroyed with it. - **Principal-bound key.** An S3 access key that authenticates a request and identifies a principal. It holds no authority of its own. -- **Service credential.** The console's tenant-wide key for traffic that has no member actor: tenant setup, bucket create and delete, and background workers. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. -- **Principal invalidation.** The Swarf record that tells every gateway to forget the proofs it cached for a principal's keys. Publishing it is how a narrowing reaches the gateway. +- **Service credential.** A tenant-wide key for traffic that has no member actor: tenant setup, bucket create and delete, and background workers. A tenant may hold several. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. +- **Principal invalidation.** The Swarf record that tells every gateway to forget the proofs it cached for a principal's keys. Publishing it is how a policy change reaches the gateway. ## Hilt - Tenant API -Two kinds of credential exist on a tenant: one service credential, and any number of access keys, each bound to a principal. The parent RFC's `POST /tenants/{tenantId}/access-keys`, which created a key with its own permissions and bucket list, is removed. Key creation moves under principals; the list, get, and delete routes at the tenant level stay. +Two kinds of credential exist on a tenant: service credentials, which reach every bucket, and access keys, each bound to a principal. The parent RFC's `POST /tenants/{tenantId}/access-keys`, which created a key with its own permissions and bucket list, is removed. Key creation moves under principals; the list, get, and delete routes at the tenant level stay. ### Tenant creation -`PUT /tenants/{tenantId}` mints the service credential during setup and includes it in the 201 body. The idempotent 200 on a repeated call does not include it. +`PUT /tenants/{tenantId}` mints a service credential during setup and includes it in the 201 body. The idempotent 200 on a repeated call does not include it. A Forge network serves one region, and a tenant is unique to its region, so the external tenant id stays the organization's UUID as the contract defines it. Hilt's one-provider-per-tenant model holds unchanged. ### Service credential -The service credential is an ed25519 `did:key`, generated and stored like an access key, at the vault path `/tenant/{tenant}/service-credential`. Hilt MUST delegate every Forge command from the tenant to the credential as powerline delegations with no expiration, exactly as the parent RFC does for a key with no bucket list. Per request, Hilt authorizes the credential for every S3 action on every bucket of the tenant and re-delegates to the gateway as the parent RFC specifies. No policy is evaluated for it. +A service credential is an ed25519 `did:key`, generated and stored like an access key, at the vault path `/tenant/{tenant}/service-credential/{accessKeyId}`. Hilt MUST delegate every Forge command from the tenant to the credential as powerline delegations with no expiration, exactly as the parent RFC does for a key with no bucket list. Per request, Hilt authorizes the credential for every S3 action on every bucket of the tenant and re-delegates to the gateway as the parent RFC specifies. No policy is evaluated for it. -The credential: +Routes, under `/tenants/{tenantId}/service-credentials` and authenticated with the partner key: -- MUST be returned once, in the body of the call that minted it, as `serviceCredential: { accessKeyId, secretAccessKey }`. -- MAY be minted for a tenant that has none through `POST /tenants/{tenantId}/service-credential`, which returns it the same way and answers 409 when one exists. This is the migration path for existing tenants. +| Method | Path | Purpose | +| ------ | ------------------------------------- | ----------------------------------------------------------- | +| POST | `/service-credentials` | Mint a credential. 201 with the secret. | +| GET | `/service-credentials` | List credentials: `accessKeyId` and `createdAt`, no secret. | +| DELETE | `/service-credentials/{accessKeyId}` | Delete a credential. 204 if already gone. | + +A credential: + +- MUST be returned once, in the body of the call that minted it, as `serviceCredential: { accessKeyId, secretAccessKey }`. A caller that loses that response mints another and deletes the one it cannot use. - MUST NOT appear in `GET /tenants/{tenantId}/access-keys`, MUST NOT count toward `accessKeyCount`, and MUST NOT be deletable through the access-key delete route. - MUST NOT be bound to a principal. It is not named in any statement and reaches every bucket of the tenant. -- Has no expiry. Rotation is a later RFC. +- Has no expiry. Rotation is minting a new credential, moving the caller to it, and deleting the old one. + +Deleting a credential MUST publish revocations for its powerline delegations before deleting the vault entry and the row, as the parent RFC's key delete does. `POST /tenants/{tenantId}/service-credentials` is also the migration path for a tenant that predates this model. ### Principals @@ -137,8 +146,9 @@ Hilt MUST reject with 422: - an action outside the [policy vocabulary](#action-vocabulary); in particular `s3:CreateBucket`, `s3:DeleteBucket`, and `s3:ListAllMyBuckets`, - a `userId` that is not a principal of the tenant, -- an empty `statements` list (the caller deletes the policy instead), -- a bucket that belongs to another tenant, reported as 404. +- an empty `statements` list (the caller deletes the policy instead). + +A bucket that does not exist or belongs to another tenant is 404 on all three routes. Hilt MUST accept a `Deny` naming `*`. The management API carries no actor; the console decides whether to write one. @@ -149,28 +159,15 @@ effective(p, b) = union(actions of Allow statements naming p or *) \ union(actions of Deny statements naming p or *) ``` -plus `s3:ListAllMyBuckets` (see [action vocabulary](#action-vocabulary)). A bucket with no policy has an empty effective set for every principal. The service credential is not a principal and is not evaluated against policies. +A bucket with no policy has an empty effective set for every principal. `s3:ListAllMyBuckets` is outside the per-bucket set: every principal holds it, and `ListBuckets` is authorized without consulting any policy (see [action vocabulary](#action-vocabulary)). A service credential is not a principal and is not evaluated against policies. **Compare-and-set.** `GET` returns a strong `ETag` computed over Hilt's canonical encoding of the stored document. `PUT` and `DELETE` MUST carry `If-Match` with that value; a mismatch is 412 and nothing is written. A `PUT` creating the first policy carries `If-None-Match: *` and is 412 if a policy already exists. Callers treat the ETag as opaque. A successful `PUT` returns the new `ETag`. **Storage.** Hilt stores the document with its bucket and maintains an index from principal to the buckets whose statements name it, so the two principal reads above cost an index lookup. Statements naming `*` are indexed under the tenant. The policy row is deleted with the bucket. -### Bucket creation with a policy - -A bucket created over S3 with the service credential has no policy, so no principal reaches it until the console writes one. To close that window the Tenant API gains a bucket create: - -`POST /tenants/{tenantId}/buckets` - -```jsonc -{ - "name": "photos", - "policy": { "statements": [ /* ... */ ] } // optional -} -``` +### Buckets without a policy -Hilt MUST create the bucket exactly as `/s3/bucket/create` does (ephemeral bucket key, root delegation to the tenant, space provisioned with Sprue, routing policy applied) and commit the bucket and its policy together after Sprue provisioning succeeds. A failure leaves no bucket. The response is 201 with the bucket's name, DID, creation time, and the policy's `ETag`. 409 if the name is taken. - -The gateway has not seen a bucket created here. Ingot registers it on its first `/s3/bucket/info` for the name (see [Ingot - S3 API](#ingot---s3-api)). +A bucket created over S3 with a service credential has no policy, and no principal reaches it until one is written. The console creates a bucket over S3 and writes its policy with `If-None-Match: *` before answering the member, so a member never learns of a bucket they cannot open. A console failure between the two calls leaves a bucket only service credentials reach. The console writes the policy on its retry, since a repeated `CreateBucket` for a name the tenant owns is reported as already owned. ### Principal-bound access keys @@ -189,27 +186,29 @@ The console holds one such key per member for its own traffic, minted on the mem `GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. The key list does not carry each key's access; the console reads a principal's access once through the principal route instead of once per key. -`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` is unchanged. It MUST publish a principal invalidation before deleting the key (see [narrowing](#narrowing-and-principal-invalidation)), because the key holds no delegation of its own that a revocation could name. +`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` is unchanged. It MUST publish a principal invalidation before deleting the key (see [principal invalidation](#policy-changes-and-principal-invalidation)), because the key holds no delegation of its own that a revocation could name. Access keys count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. -### Narrowing and principal invalidation +### Policy changes and principal invalidation + +A policy `PUT` or `DELETE` changes the effective set of the principals it names, `*` expanding to every principal of the tenant. Hilt MUST compute, for each principal named in the old or new document, whether its effective set on the bucket changed, and MUST publish a principal invalidation for each such principal before acknowledging the write. Deleting one of a principal's keys and removing a principal each publish one invalidation for that principal. -A narrowing is any change that removes an action a principal held on a bucket: a policy `PUT` or `DELETE` whose effective set for the principal shrinks, the deletion of one of the principal's keys, or the removal of the principal. Hilt MUST publish a principal invalidation for each affected principal before acknowledging the change. +The gateway caches proof chains and effective action sets per access key until the next UTC midnight. Nothing in those chains identifies the principal, so no delegation revocation can clear them. The invalidation record names the principal directly (see [Swarf - principal invalidation](#swarf---principal-invalidation)), and the gateway drops every store it holds for that principal's keys. A widening needs the record as much as a narrowing: the gateway refuses an action outside a cached set on its own, so a grant would otherwise wait for the cache to expire. -The gateway caches proof chains and effective action sets per access key until the next UTC midnight. Nothing in those chains identifies the principal, so no delegation revocation can clear them. The invalidation record names the principal directly (see [Swarf - principal invalidation](#swarf---principal-invalidation)), and the gateway drops every store it holds for that principal's keys. +Hilt MUST publish inside the transaction that commits the change, after locking the rows the change writes and before committing them. `/s3/request/authorize` reads the bucket's policy row, the key row, and the principal row with `SELECT ... FOR SHARE`, so an authorize request that arrives between the publish and the commit waits for the commit and is answered from the new state. Without the lock, a gateway that had consumed the record could refill its cache from the old policy and hold it until midnight. Authorize requests for a bucket wait one Swarf round trip during a write to that bucket's policy. The lock does not cover an authorize answered before the lock was taken whose response reaches the gateway after the record does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. -If publishing fails, Hilt MUST return 500 and MUST NOT commit the change. A retry publishes again. A failed narrowing leaves the principal with its old access and never with more. +If publishing fails, Hilt MUST return 500 and MUST NOT commit the change. A retry publishes again. A failed change leaves the principal with its old access and never with more. A record published for a change that did not commit is harmless: the gateway refills from the state Hilt holds. -A policy write MUST compute, for each principal named in the old or new document (expanding `*` to every principal of the tenant), whether its effective set on the bucket lost any action, and publish one invalidation per such principal. A widening publishes nothing. Deleting a key publishes one invalidation for the key's principal; the principal's other keys lose their cached proofs too and refill on their next request. +Deleting a key publishes one invalidation for the key's principal; the principal's other keys lose their cached proofs too and refill on their next request. -The staleness bound for a narrowing is the time from Hilt's acknowledgement to the gateway consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The record clears gateway caches and nothing else: a per-request delegation the gateway already holds in flight stays valid at Sprue and Piri until its expiry, because those services check revocation by delegation CID and the record names none. +The staleness bound for a change is the time from Hilt's acknowledgement to the gateway consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The record clears gateway caches and nothing else: a per-request delegation the gateway already holds in flight stays valid at Sprue and Piri until its expiry, because those services check revocation by delegation CID and the record names none. ### Principal removal -`DELETE /tenants/{tenantId}/principals/{userId}` MUST, in order: +`DELETE /tenants/{tenantId}/principals/{userId}` MUST, in one transaction and in order: -1. Publish a principal invalidation. +1. Lock the principal row and publish a principal invalidation. 2. Remove the principal from every statement naming it, deleting statements left with no principal and policies left with no statement. 3. Delete the principal's keys: vault entries and rows. 4. Delete the principal row. @@ -218,9 +217,9 @@ A removed member who is later re-invited gets a new principal row. No statement ### Bucket deletion -`/s3/bucket/delete` deletes the bucket's policy with the bucket. There is nothing to revoke: principals hold no delegations, and per-request delegations are not stored. Hilt publishes nothing. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. +`/s3/bucket/delete` deletes the bucket's policy with the bucket. There is nothing to revoke: principals hold no delegations, and per-request delegations are not stored. Hilt publishes nothing. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. This holds because a Forge network runs one gateway: Ingot's registry and caches are local to the instance, and a second gateway serving the same network would keep serving the bucket from its own caches until they expired. A network with several gateways needs a bucket invalidation record, which this RFC does not define (see [open questions](#open-questions)). -Deleting a tenant cascades over principals, policies, keys, and the service credential. As today, it publishes no revocations and deactivates the tenant's `did:plc`. +Deleting a tenant cascades over principals, policies, keys, and service credentials. As today, it publishes no revocations and deactivates the tenant's `did:plc`. ## Hilt - UCAN API @@ -228,7 +227,7 @@ The commands and their argument types are unchanged. The result types gain one f ### `/s3/request/authorize` -For a request signed with the service credential, the parent RFC's steps apply with every action permitted on every bucket of the tenant. +For a request signed with a service credential, the parent RFC's steps apply with every action permitted on every bucket of the tenant. For a request signed with an access key, Hilt performs the parent RFC's steps through signature verification, tenant status, issuer, and region, then: @@ -259,7 +258,7 @@ The expiry rule is the parent RFC's. Per-request delegations are returned in the type AuthorizeOK struct { bucket optional String # bucket DID; absent for ListBuckets and CreateBucket tenant String # tenant DID - principal optional String # principal userId; absent for the service credential + principal optional String # principal userId; absent for a service credential permissions { String: [String] } # access key DID → effective actions on `bucket` keys { String: [VerificationKey] } delegations { String: [Link] } @@ -281,7 +280,7 @@ type AuthorizeOK struct { ``` -For `ListBuckets` the effective actions are `["s3:ListAllMyBuckets"]`. For the service credential `permissions` carries the full vocabulary. `tenant` and `principal` together identify the principal, and are what the gateway indexes its caches by. +For `ListBuckets` the effective actions are `["s3:ListAllMyBuckets"]`. For a service credential `permissions` carries the full vocabulary. `tenant` and `principal` together identify the principal, and are what the gateway indexes its caches by. ### `/s3/bucket/info` @@ -291,7 +290,7 @@ For an access key the chain is the bucket root alone, bucket → tenant with `cm ### `/s3/bucket/create` and `/s3/bucket/delete` -Unchanged for the service credential. An access key receives `OperationNotPermitted` from both. Bucket deletion additionally removes the policy, as described above. +Unchanged for a service credential. An access key receives `OperationNotPermitted` from both. Bucket deletion additionally removes the policy, as described above. ### `/s3/bucket/list` @@ -357,12 +356,10 @@ The cursor, the inclusive resume rule, and deduplication by `cause` are the same Ingot's request path is unchanged: authorize locally from cache when it can, and ask Hilt otherwise. -**Ingot MUST enforce the effective action set.** Today Ingot discards the `permissions` field and authorizes locally by checking that its cache holds a proof chain for each Forge command the operation needs. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone therefore cannot enforce a `Deny` on one of them, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so an invalidation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. +**Ingot MUST enforce the effective action set.** Today Ingot discards the `permissions` field and authorizes locally by checking that its cache holds a proof chain for each Forge command the operation needs. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone therefore cannot enforce a `Deny` on one of them, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so an invalidation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. The two bucket-configuration reads map to no Forge command and are authorized at Hilt on every request, as every bucket-level operation is today. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. Ingot serves the value from its registry once Hilt answers. **The firehose consumer gains a branch.** It drops every per-key store containing a revoked CID today. On a `principal` event it MUST drop every store for access keys whose authorize result carried the event's tenant and principal, which requires Ingot to index its per-key stores by that pair. Dropping the proof stores is enough; the derived signing keys may stay, since a request with a signing key and no chain goes to Hilt. Like the existing revoker, the handler is idempotent and an event matching nothing is a no-op. -**Buckets created through the Tenant API.** Ingot registers a bucket in its own registry only inside its `CreateBucket` handler today. It MUST also register a bucket it first learns of from a successful `/s3/bucket/info`, with default versioning and object-lock state, so a bucket created by `POST /tenants/{tenantId}/buckets` is servable. - Error mapping needs no change: `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. ## Action vocabulary @@ -400,7 +397,7 @@ Forge runs demo and dev environments only, so existing tenants are migrated in p 1. Deploy Swarf with the publisher list, and Ingot with the effective-action check and the `principal` event branch, on every gateway serving the network. 2. Deploy Hilt. Its migration deletes every existing access key, publishing revocations for their delegations first, and drops the key's `permissions` and `buckets` columns. Tenants, buckets, and bucket root delegations are untouched. -3. The console calls `POST /tenants/{tenantId}/service-credential` for each existing tenant and stores the result where it keeps the tenant's console credential today. +3. The console calls `POST /tenants/{tenantId}/service-credentials` for each existing tenant and stores the result where it keeps the tenant's console credential today. 4. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{userId}`, then writes a policy per bucket naming the org's Owners and Admins. 5. The console flips the region's registry entry to `iam`. @@ -442,14 +439,14 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac 2. A policy edit naming `*` publishes one invalidation per principal of the tenant. There is no figure for principals per tenant on Forge. If it is large, that fan-out is the cost to watch. 3. Swarf trusts the publisher list and checks no proof. Whether Swarf should verify that a tenant owns the named principal, for example by consulting Hilt, is open. 4. Limits on statements per policy and principals per statement. None are specified here. -5. Rotation of the service credential. The credential has no expiry and no rotate route. +5. Bucket invalidation for a network with several gateways. Bucket deletion clears only the gateway that handled it, and Ingot's registry is per instance, so such a network needs a record the firehose carries and a registry the gateways share. ## Evaluation criteria -- The time from a narrowing's acknowledgement to the gateway's first refusal, measured against a warm key. This is the staleness bound the ADR asks Hilt to publish. -- Authorization latency for the service credential is unchanged from today's tenant-wide key. +- The time from a change's acknowledgement to the gateway's first refusal, measured against a warm key. This is the staleness bound the ADR asks Hilt to publish. +- Authorization latency for a service credential is unchanged from today's tenant-wide key. - The console's in-memory IAM fake and Hilt pass the same contract tests. -- A policy `PUT` costs one Swarf record per principal whose access shrank, and no delegation writes. +- A policy `PUT` costs one Swarf record per principal whose access changed, and no delegation writes. ## References @@ -498,20 +495,22 @@ CREATE TABLE bucket_policy_principal ( bucket_id TEXT NOT NULL REFERENCES bucket(id) ON DELETE CASCADE, tenant_id TEXT NOT NULL, principal TEXT, - FOREIGN KEY (tenant_id, principal) REFERENCES principal(tenant_id, external_id) ON DELETE CASCADE, - UNIQUE (bucket_id, principal) + FOREIGN KEY (tenant_id, principal) 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) WHERE principal IS NOT NULL; +CREATE UNIQUE INDEX bucket_policy_principal_wildcard_idx ON bucket_policy_principal (bucket_id) WHERE principal IS NULL; CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (tenant_id, principal, bucket_id); ``` -The `delegation` table is unchanged. The vault gains `/tenant/{tenant}/service-credential` and nothing for principals. +The `delegation` table is unchanged. The vault gains `/tenant/{tenant}/service-credential/{accessKeyId}` and nothing for principals. ### Example flows #### Migrate an existing tenant 1. Hilt's migration has already deleted the tenant's keys and published their revocations. -2. Fil One calls `POST /tenants/{tenantId}/service-credential`. Hilt mints the credential, issues its powerline delegations, and returns it. +2. Fil One calls `POST /tenants/{tenantId}/service-credentials`. Hilt mints the credential, issues its powerline delegations, and returns it. 3. Fil One calls `PUT /tenants/{tenantId}/principals/{userId}` for every member. 4. Hilt stores each principal. 5. Fil One writes each bucket's policy. @@ -520,8 +519,8 @@ The `delegation` table is unchanged. The vault gains `/tenant/{tenant}/service-c 1. Fil One reads `GET /tenants/{t}/buckets/photos/policy` and its `ETag`. 2. Fil One writes the document with an added `Allow` statement, sending `If-Match`. -3. Hilt compares effective sets before and after for every named principal. Nobody lost an action, so nothing is published. Hilt stores the document and returns the new `ETag`. -4. The member's next request on `photos` misses the gateway cache, reaches Hilt, and is authorized against the new policy. +3. Hilt compares effective sets before and after for every named principal. The added member gained actions, so Hilt locks the policy row, invokes `/principal/invalidate` on Swarf for them, and commits the document. The response carries the new `ETag`. +4. Swarf's firehose delivers the `principal` event and Ingot drops every cached store for the member's keys. The member's next request on `photos` reaches Hilt and is authorized against the new policy. #### Put an object with an access key @@ -534,12 +533,12 @@ The `delegation` table is unchanged. The vault gains `/tenant/{tenant}/service-c #### Remove a member from a bucket 1. Fil One writes the policy without the member's statement, with `If-Match`. -2. Hilt finds the member's effective set on the bucket shrank, invokes `/principal/invalidate` on Swarf for the member, and commits the document. +2. Hilt finds the member's effective set on the bucket shrank, locks the policy row, invokes `/principal/invalidate` on Swarf for the member, and commits the document. An authorize request for `photos` that arrives meanwhile waits for the commit. 3. Swarf's firehose delivers the `principal` event. Ingot drops every cached store for the member's keys. 4. The member's next request on any bucket reaches Hilt. On the removed bucket it receives `UnknownBucket`, which Ingot renders as `NoSuchBucket`. #### Remove a member 1. Fil One calls `DELETE /tenants/{t}/principals/{userId}`. -2. Hilt invokes `/principal/invalidate` on Swarf, strips the principal from every statement, deletes its keys, and deletes the principal row. +2. Hilt locks the principal row, invokes `/principal/invalidate` on Swarf, strips the principal from every statement, deletes its keys, and deletes the principal row. 3. Ingot drops the member's cached stores on the `principal` event. Their keys no longer resolve at Hilt. From 17eab0a6aa868747fb8a67d2294773acc1628518 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Thu, 10 Sep 2026 18:00:12 +0200 Subject: [PATCH 05/40] rfc: self-signed invalidation, migrate CLI, locked principal removal The invalidation invocation names Hilt as its own subject, the shape the revoke command already uses, because the standard validator refuses a foreign subject with no proofs. Existing keys are removed by `hilt migrate iam` before the schema migration, which now refuses to run while a key remains. A service credential stores its accessKeyId as its name so the schema is unchanged. Principal removal holds the principal row lock across idempotent cleanup steps instead of claiming one transaction over tables and the vault. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 910b912..75bf624 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -74,6 +74,7 @@ A credential: - MUST NOT appear in `GET /tenants/{tenantId}/access-keys`, MUST NOT count toward `accessKeyCount`, and MUST NOT be deletable through the access-key delete route. - MUST NOT be bound to a principal. It is not named in any statement and reaches every bucket of the tenant. - Has no expiry. Rotation is minting a new credential, moving the caller to it, and deleting the old one. +- Is stored with its `accessKeyId` as its `name`, so the tenant-wide unique name constraint holds. The name is never returned. Deleting a credential MUST publish revocations for its powerline delegations before deleting the vault entry and the row, as the parent RFC's key delete does. `POST /tenants/{tenantId}/service-credentials` is also the migration path for a tenant that predates this model. @@ -206,12 +207,14 @@ The staleness bound for a change is the time from Hilt's acknowledgement to the ### Principal removal -`DELETE /tenants/{tenantId}/principals/{userId}` MUST, in one transaction and in order: +`DELETE /tenants/{tenantId}/principals/{userId}` MUST lock the principal row for the whole removal and, in order: -1. Lock the principal row and publish a principal invalidation. +1. Publish a principal invalidation. 2. Remove the principal from every statement naming it, deleting statements left with no principal and policies left with no statement. 3. Delete the principal's keys: vault entries and rows. -4. Delete the principal row. +4. Delete the principal row and commit, releasing the lock. + +Steps 2 and 3 are idempotent. A failure before step 4 rolls back the principal row and leaves the principal with narrower access; the call is retried and answers 204 once the principal is gone. An authorize request for the principal waits on the lock until the removal commits or rolls back. A removed member who is later re-invited gets a new principal row. No statement survives to restore their old access. @@ -308,7 +311,7 @@ Swarf gains one command and one firehose event. Both carry a principal identity * Issuer: Hilt, as its service identity (`did:web:auth.`) * Audience: Swarf -* Subject: Swarf +* Subject: Hilt, the issuer's own DID Records that every proof a gateway cached for the principal's keys is void. @@ -338,7 +341,7 @@ A unit result (`{}`). #### Authorization -There is no delegation to the principal, so no witness path can prove the invoker's authority over it. Swarf MUST accept the command only from issuers in a configured publisher list, and MUST refuse it from anyone else. The list holds the Hilt service identities of the networks Swarf serves. +There is no delegation to the principal, so no witness path can prove the invoker's authority over it. Hilt self-signs the invocation with its own DID as subject, the shape `/ucan/revoke` already uses, so a standard UCAN validator accepts it without proofs. Swarf MUST accept the command only from issuers in a configured publisher list, and MUST refuse it from anyone else. The list holds the Hilt service identities of the networks Swarf serves. ### Firehose @@ -396,7 +399,7 @@ Retention and legal-hold writes pass through the API like any other action. The Forge runs demo and dev environments only, so existing tenants are migrated in place. Per Forge network: 1. Deploy Swarf with the publisher list, and Ingot with the effective-action check and the `principal` event branch, on every gateway serving the network. -2. Deploy Hilt. Its migration deletes every existing access key, publishing revocations for their delegations first, and drops the key's `permissions` and `buckets` columns. Tenants, buckets, and bucket root delegations are untouched. +2. Run `hilt migrate iam` against the network's database. It publishes revocations for every existing access key's delegations, then deletes each key's delegations, vault entry, and row. Deploy Hilt. Its schema migration refuses to run while any access key remains, and drops the key's `permissions` and `buckets` columns. Tenants, buckets, and bucket root delegations are untouched. 3. The console calls `POST /tenants/{tenantId}/service-credentials` for each existing tenant and stores the result where it keeps the tenant's console credential today. 4. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{userId}`, then writes a policy per bucket naming the org's Owners and Admins. 5. The console flips the region's registry entry to `iam`. @@ -471,7 +474,8 @@ CREATE TABLE principal ( PRIMARY KEY (tenant_id, external_id) ); -DELETE FROM access_key; -- revocations published by the migration first +-- The migration aborts if any access_key row remains: `hilt migrate iam` +-- publishes their revocations and deletes them first. ALTER TABLE access_key DROP COLUMN permissions, DROP COLUMN buckets, @@ -479,6 +483,7 @@ ALTER TABLE access_key ADD COLUMN service BOOLEAN NOT NULL DEFAULT FALSE, ADD FOREIGN KEY (tenant_id, principal) REFERENCES principal(tenant_id, external_id) ON DELETE RESTRICT, ADD CONSTRAINT access_key_kind CHECK (service <> (principal IS NOT NULL)); +-- name keeps its NOT NULL and UNIQUE (tenant_id, name); a service credential stores its accessKeyId there. CREATE UNIQUE INDEX access_key_principal_name_idx ON access_key (tenant_id, principal, name) WHERE principal IS NOT NULL; @@ -509,7 +514,7 @@ The `delegation` table is unchanged. The vault gains `/tenant/{tenant}/service-c #### Migrate an existing tenant -1. Hilt's migration has already deleted the tenant's keys and published their revocations. +1. `hilt migrate iam` has already published revocations for the tenant's keys and deleted them. 2. Fil One calls `POST /tenants/{tenantId}/service-credentials`. Hilt mints the credential, issues its powerline delegations, and returns it. 3. Fil One calls `PUT /tenants/{tenantId}/principals/{userId}` for every member. 4. Hilt stores each principal. From 747bed9f093bd2ad25189f8a671dbae4560d9d60 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 11 Sep 2026 10:21:01 +0200 Subject: [PATCH 06/40] rfc: one access-key route with an optional principal An access key gains an optional principalId at the parent RFC's POST /tenants/{tenantId}/access-keys. With it, the key is bound to a principal and carries whatever the bucket policies give them. Without it, the key is a service key: the parent RFC's key, authorized from its own permissions and buckets. This drops the separate service-credential and per-principal creation routes, the second vault path, and the migration that revoked and deleted every existing key. Existing keys are already service keys, so the schema adds one nullable principal column with no backfill, hilt migrate iam goes away, and the rollout has no window in which the network refuses requests. A member's access stays what their old key carries until the console moves them. Also: 422 rather than silently ignoring permissions or buckets sent with a principalId, tenant creation mints no key, service-key names stay unique per tenant while principal-bound names are unique per principal, and the service boolean column goes since a null principal identifies the kind. Co-Authored-By: Claude Opus 5 (1M context) --- rfcs/2026-09-forge-s3-tenant-iam.md | 148 ++++++++++++++++------------ 1 file changed, 84 insertions(+), 64 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 75bf624..6634928 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -12,7 +12,7 @@ Extends: [Forge S3 tenant management](./2026-06-forge-s3-tenant-management.md) Hilt gains principals, bucket policies, and access keys bound to principals, and Ingot enforces the actions those policies grant. Together they give the Fil One console what it needs to grant a member a subset 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. -This model replaces the [parent RFC's](./2026-06-forge-s3-tenant-management.md) access keys. Its tenant, bucket, and delegation mechanics stay in force. +An access key gains an optional principal. A key created with one carries whatever the bucket policies give that principal. A key created without one keeps the [parent RFC's](./2026-06-forge-s3-tenant-management.md) behavior, authorized from the permissions and buckets it was created with. The parent RFC's tenant, bucket, and delegation mechanics stay in force, and its access-key routes are unchanged. ## Motivation @@ -25,8 +25,8 @@ On Forge the ADR settles three things: each member is a principal at the storage 1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `Allow` minus `Deny`, with an explicit `Deny` winning. 2. A policy edit changes what every key bound to a named principal may do, with no key reissued. 3. Every change to a principal's access is published to Swarf before Hilt acknowledges it, and Ingot's firehose consumer clears the gateway's warm caches within firehose latency. -4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps a tenant-wide service credential for traffic that has no member actor. -5. Hilt has one authorization model. Existing tenants move to it in place. +4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps a service key for traffic that has no member actor. +5. One policy model governs every principal. Existing keys keep working, and a tenant moves to principals with no key reissued and no window in which the network refuses requests. ## Concepts @@ -43,40 +43,42 @@ The parent RFC's roles apply. Swarf is added: - **Principal.** A member as Hilt knows them: `(tenant, userId)` and nothing more. A principal carries no permissions, no role, and no key material. - **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. The policy is created with its bucket and destroyed with it. - **Principal-bound key.** An S3 access key that authenticates a request and identifies a principal. It holds no authority of its own. -- **Service credential.** A tenant-wide key for traffic that has no member actor: tenant setup, bucket create and delete, and background workers. A tenant may hold several. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. +- **Service key.** An access key with no principal: the parent RFC's key, carrying the permissions and buckets it was created with. It signs traffic that has no member actor, such as tenant setup, bucket create and delete, and background workers. A tenant may hold several. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. - **Principal invalidation.** The Swarf record that tells every gateway to forget the proofs it cached for a principal's keys. Publishing it is how a policy change reaches the gateway. ## Hilt - Tenant API -Two kinds of credential exist on a tenant: service credentials, which reach every bucket, and access keys, each bound to a principal. The parent RFC's `POST /tenants/{tenantId}/access-keys`, which created a key with its own permissions and bucket list, is removed. Key creation moves under principals; the list, get, and delete routes at the tenant level stay. +`POST /tenants/{tenantId}/access-keys` gains one optional field, `principalId`, and that field decides which kind of key it creates: + +| `principalId` | Kind | Authority | +| ------------- | ------------------- | --------------------------------------------------------------------------- | +| absent | Service key | The `permissions` and `buckets` in the request, as the parent RFC specifies. | +| present | Principal-bound key | Whatever the bucket policies give the principal, evaluated per request. | + +Hilt MUST reject with 422 a request carrying `principalId` together with a non-empty `permissions` or `buckets`. A caller that asks for a narrowed key would get one whose reach the policies decide, and neither the create response nor a later `GET` would show the difference. The management API requires `permissions` on every create today. That requirement applies only to a create without `principalId`. + +`GET`, `DELETE`, and the tenant's key list serve both kinds. ### Tenant creation -`PUT /tenants/{tenantId}` mints a service credential during setup and includes it in the 201 body. The idempotent 200 on a repeated call does not include it. +`PUT /tenants/{tenantId}` is unchanged. The console creates the tenant and then calls `POST /tenants/{tenantId}/access-keys` with no `principalId` for the credential it provisions buckets with, as it does today. A Forge network serves one region, and a tenant is unique to its region, so the external tenant id stays the organization's UUID as the contract defines it. Hilt's one-provider-per-tenant model holds unchanged. -### Service credential +### Service keys -A service credential is an ed25519 `did:key`, generated and stored like an access key, at the vault path `/tenant/{tenant}/service-credential/{accessKeyId}`. Hilt MUST delegate every Forge command from the tenant to the credential as powerline delegations with no expiration, exactly as the parent RFC does for a key with no bucket list. Per request, Hilt authorizes the credential for every S3 action on every bucket of the tenant and re-delegates to the gateway as the parent RFC specifies. No policy is evaluated for it. +A service key is the parent RFC's access key, unchanged: an ed25519 `did:key` at the vault path `/tenant/{tenant}/access/{accessKeyId}`. It holds one delegation from the tenant per bucket and Forge command mapped from its permissions, or a powerline delegation with an undefined subject when it was created with no bucket list. -Routes, under `/tenants/{tenantId}/service-credentials` and authenticated with the partner key: +A service key: -| Method | Path | Purpose | -| ------ | ------------------------------------- | ----------------------------------------------------------- | -| POST | `/service-credentials` | Mint a credential. 201 with the secret. | -| GET | `/service-credentials` | List credentials: `accessKeyId` and `createdAt`, no secret. | -| DELETE | `/service-credentials/{accessKeyId}` | Delete a credential. 204 if already gone. | +- Reaches the buckets in its list, or every bucket of the tenant when it has none. It is named in no 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, as today. +- Keeps the parent RFC's optional `expiresAt`. Rotation is minting a new key, moving the caller to it, and deleting the old one. -A credential: +Its bucket list is fixed at creation, so a service key scoped to named buckets cannot reach a bucket created afterwards. Tenant setup, bucket create, and bucket delete therefore use a key with no bucket list. -- MUST be returned once, in the body of the call that minted it, as `serviceCredential: { accessKeyId, secretAccessKey }`. A caller that loses that response mints another and deletes the one it cannot use. -- MUST NOT appear in `GET /tenants/{tenantId}/access-keys`, MUST NOT count toward `accessKeyCount`, and MUST NOT be deletable through the access-key delete route. -- MUST NOT be bound to a principal. It is not named in any statement and reaches every bucket of the tenant. -- Has no expiry. Rotation is minting a new credential, moving the caller to it, and deleting the old one. -- Is stored with its `accessKeyId` as its `name`, so the tenant-wide unique name constraint holds. The name is never returned. - -Deleting a credential MUST publish revocations for its powerline delegations before deleting the vault entry and the row, as the parent RFC's key delete does. `POST /tenants/{tenantId}/service-credentials` is also the migration path for a tenant that predates this model. +Deleting a service key MUST publish revocations for its delegations before deleting the vault entry and the row, as the parent RFC's key delete does. ### Principals @@ -90,9 +92,10 @@ Routes, all under `/tenants/{tenantId}/principals` and authenticated with the pa | DELETE | `/principals/{userId}` | Remove the principal (see [removal](#principal-removal)). 204 if already gone. | | GET | `/principals/{userId}/policies` | Every bucket policy with a statement naming the principal or `*`. | | GET | `/principals/{userId}/access` | The principal's effective actions per bucket. | -| POST | `/principals/{userId}/access-keys` | Issue a key bound to the principal. | | GET | `/principals/{userId}/access-keys` | List the principal's keys. | +A key bound to a principal is created at the tenant's access-key route with `principalId` set (see [principal-bound access keys](#principal-bound-access-keys)). + `userId` is an opaque string supplied by the caller, unique within the tenant. A principal is represented as: @@ -160,7 +163,7 @@ effective(p, b) = union(actions of Allow statements naming p or *) \ union(actions of Deny statements naming p or *) ``` -A bucket with no policy has an empty effective set for every principal. `s3:ListAllMyBuckets` is outside the per-bucket set: every principal holds it, and `ListBuckets` is authorized without consulting any policy (see [action vocabulary](#action-vocabulary)). A service credential is not a principal and is not evaluated against policies. +A bucket with no policy has an empty effective set for every principal. `s3:ListAllMyBuckets` is outside the per-bucket set: every principal holds it, and `ListBuckets` is authorized without consulting any policy (see [action vocabulary](#action-vocabulary)). A service key is not a principal and is not evaluated against policies. **Compare-and-set.** `GET` returns a strong `ETag` computed over Hilt's canonical encoding of the stored document. `PUT` and `DELETE` MUST carry `If-Match` with that value; a mismatch is 412 and nothing is written. A `PUT` creating the first policy carries `If-None-Match: *` and is 412 if a policy already exists. Callers treat the ETag as opaque. A successful `PUT` returns the new `ETag`. @@ -168,28 +171,31 @@ A bucket with no policy has an empty effective set for every principal. `s3:List ### Buckets without a policy -A bucket created over S3 with a service credential has no policy, and no principal reaches it until one is written. The console creates a bucket over S3 and writes its policy with `If-None-Match: *` before answering the member, so a member never learns of a bucket they cannot open. A console failure between the two calls leaves a bucket only service credentials reach. The console writes the policy on its retry, since a repeated `CreateBucket` for a name the tenant owns is reported as already owned. +A bucket created over S3 with a service key has no policy, and no principal reaches it until one is written. The console creates a bucket over S3 and writes its policy with `If-None-Match: *` before answering the member, so a member never learns of a bucket they cannot open. A console failure between the two calls leaves a bucket only service keys reach. The console writes the policy on its retry, since a repeated `CreateBucket` for a name the tenant owns is reported as already owned. ### Principal-bound access keys -`POST /tenants/{tenantId}/principals/{userId}/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 } ``` -Hilt MUST generate and store the key exactly as the parent RFC specifies for an access key, and MUST record the principal on the key row. It MUST NOT issue any delegation to the key. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `userId` in place of `permissions` and `buckets`. 409 on a duplicate name for that principal. +Hilt MUST generate and store the key exactly as the parent RFC specifies, and MUST record the principal on the key row. It MUST NOT issue any delegation to the key, and MUST store an empty permission set and no bucket list. 422 on a `principalId` that is not a principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `userId` in place of `permissions` and `buckets`. + +A service key's name stays unique within the tenant, as today. A principal-bound key's name is unique within its principal, so two members may each hold a key named `laptop`. The console holds one such key per member for its own traffic, minted on the member's first request in the region, and signs that member's bucket listing, object listing, object reads and deletes, and presigned URLs with it. Hilt does not distinguish it from a key the member created: it is bound to the same principal, authorized against the same policies, and deleted with the principal. A presigned URL signed with it is authorized against the member's policies when it is redeemed, with the parent RFC's signature time bounds and the key's own expiry applying as for any request. -`GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. The key list does not carry each key's access; the console reads a principal's access once through the principal route instead of once per key. +`GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. A key without it is a service key and carries its own `permissions` and `buckets`. The key list does not carry each key's access; the console reads a principal's access once through the principal route instead of once per key. -`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` is unchanged. It MUST publish a principal invalidation before deleting the key (see [principal invalidation](#policy-changes-and-principal-invalidation)), because the key holds no delegation of its own that a revocation could name. +`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` deletes either kind. For a principal-bound key it MUST publish a principal invalidation before deleting the key (see [principal invalidation](#policy-changes-and-principal-invalidation)), because the key holds no delegation of its own that a revocation could name. For a service key it publishes revocations for the key's delegations, as the parent RFC specifies. -Access keys count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. +Both kinds count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. ### Policy changes and principal invalidation @@ -222,17 +228,17 @@ A removed member who is later re-invited gets a new principal row. No statement `/s3/bucket/delete` deletes the bucket's policy with the bucket. There is nothing to revoke: principals hold no delegations, and per-request delegations are not stored. Hilt publishes nothing. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. This holds because a Forge network runs one gateway: Ingot's registry and caches are local to the instance, and a second gateway serving the same network would keep serving the bucket from its own caches until they expired. A network with several gateways needs a bucket invalidation record, which this RFC does not define (see [open questions](#open-questions)). -Deleting a tenant cascades over principals, policies, keys, and service credentials. As today, it publishes no revocations and deactivates the tenant's `did:plc`. +Deleting a tenant cascades over principals, policies, and access keys. As today, it publishes no revocations and deactivates the tenant's `did:plc`. ## Hilt - UCAN API -The commands and their argument types are unchanged. The result types gain one field, and the authorization steps gain a branch for access keys. +The commands and their argument types are unchanged. The result types gain one field, and the authorization steps gain a branch on whether the key has a principal. ### `/s3/request/authorize` -For a request signed with a service credential, the parent RFC's steps apply with every action permitted on every bucket of the tenant. +For a request signed with a service key, the parent RFC's steps apply unchanged: the action must be in the key's `permissions`, and the bucket must be in its `buckets` or the key must have none. -For a request signed with an access key, Hilt performs the parent RFC's steps through signature verification, tenant status, issuer, and region, then: +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. @@ -261,7 +267,7 @@ The expiry rule is the parent RFC's. Per-request delegations are returned in the type AuthorizeOK struct { bucket optional String # bucket DID; absent for ListBuckets and CreateBucket tenant String # tenant DID - principal optional String # principal userId; absent for a service credential + principal optional String # principal userId; absent for a service key permissions { String: [String] } # access key DID → effective actions on `bucket` keys { String: [VerificationKey] } delegations { String: [Link] } @@ -283,17 +289,17 @@ type AuthorizeOK struct { ``` -For `ListBuckets` the effective actions are `["s3:ListAllMyBuckets"]`. For a service credential `permissions` carries the full vocabulary. `tenant` and `principal` together identify the principal, and are what the gateway indexes its caches by. +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. `tenant` and `principal` together identify the principal, and are what the gateway indexes its caches by. ### `/s3/bucket/info` -For an access key the chain is the bucket root alone, bucket → tenant with `cmd: "/"`. Neither the principal nor the key is a hop. +For a principal-bound key the chain is the bucket root alone, bucket → tenant with `cmd: "/"`. Neither the principal nor the key is a hop. `InfoOK` gains the same optional `principal` field, and `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` -Unchanged for a service credential. An access key receives `OperationNotPermitted` from both. Bucket deletion additionally removes the policy, as described above. +Unchanged for a service key. It reaches them by holding `s3:CreateBucket` or `s3:DeleteBucket` in its permissions. A principal-bound key receives `OperationNotPermitted` from both. Bucket deletion additionally removes the policy, as described above. ### `/s3/bucket/list` @@ -361,7 +367,7 @@ Ingot's request path is unchanged: authorize locally from cache when it can, and **Ingot MUST enforce the effective action set.** Today Ingot discards the `permissions` field and authorizes locally by checking that its cache holds a proof chain for each Forge command the operation needs. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone therefore cannot enforce a `Deny` on one of them, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so an invalidation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. The two bucket-configuration reads map to no Forge command and are authorized at Hilt on every request, as every bucket-level operation is today. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. Ingot serves the value from its registry once Hilt answers. -**The firehose consumer gains a branch.** It drops every per-key store containing a revoked CID today. On a `principal` event it MUST drop every store for access keys whose authorize result carried the event's tenant and principal, which requires Ingot to index its per-key stores by that pair. Dropping the proof stores is enough; the derived signing keys may stay, since a request with a signing key and no chain goes to Hilt. Like the existing revoker, the handler is idempotent and an event matching nothing is a no-op. +**The firehose consumer gains a branch.** It drops every per-key store containing a revoked CID today. On a `principal` event it MUST drop every store for access keys whose authorize result carried the event's tenant and principal, which requires Ingot to index its per-key stores by that pair. Dropping the proof stores is enough; the derived signing keys may stay, since a request with a signing key and no chain goes to Hilt. Like the existing revoker, the handler is idempotent and an event matching nothing is a no-op. A service key's authorize result carries no principal, so no `principal` event ever matches it. Its store is cleared by the revocation path it uses today. Error mapping needs no change: `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. @@ -388,7 +394,7 @@ The policy vocabulary is Hilt's S3 permission set without the three bucket-level | `s3:GetBucketVersioning` | none | new; served by Ingot from its registry | | `s3:GetBucketObjectLockConfiguration` | none | new; served by Ingot from its registry | -Excluded from policies: `s3:CreateBucket` and `s3:DeleteBucket`, because a key holding them acts outside the policy that granted it, and `s3:ListAllMyBuckets`, which every principal holds. +Excluded from policies: `s3:CreateBucket` and `s3:DeleteBucket`, because a principal holding them acts outside the policy that granted it, and `s3:ListAllMyBuckets`, which every principal holds. A service key still holds the two bucket actions through its own permissions, which is how the console creates and deletes buckets. The two bucket-configuration reads are new to Hilt. Its operation classifier MUST recognize `GET /{bucket}?versioning` as `GetBucketVersioning` and `GET /{bucket}?object-lock` as `GetBucketObjectLockConfiguration`; today both classify as `ListBucket`. The management API's action enum, now used by policy statements, gains both, along with `s3:AbortMultipartUpload` and `s3:ListMultipartUploadParts`, which Hilt already accepts. @@ -396,15 +402,15 @@ Retention and legal-hold writes pass through the API like any other action. The ## Migration -Forge runs demo and dev environments only, so existing tenants are migrated in place. Per Forge network: +Every existing key becomes a service key, because a key with no principal is exactly the parent RFC's key. Per Forge network: 1. Deploy Swarf with the publisher list, and Ingot with the effective-action check and the `principal` event branch, on every gateway serving the network. -2. Run `hilt migrate iam` against the network's database. It publishes revocations for every existing access key's delegations, then deletes each key's delegations, vault entry, and row. Deploy Hilt. Its schema migration refuses to run while any access key remains, and drops the key's `permissions` and `buckets` columns. Tenants, buckets, and bucket root delegations are untouched. -3. The console calls `POST /tenants/{tenantId}/service-credentials` for each existing tenant and stores the result where it keeps the tenant's console credential today. -4. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{userId}`, then writes a policy per bucket naming the org's Owners and Admins. -5. The console flips the region's registry entry to `iam`. +2. Deploy Hilt. Its schema migration adds the `principal` column and its indexes. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. +3. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{userId}`, then writes a policy per bucket naming the org's Owners and Admins. +4. The console flips the region's registry entry to `iam` and starts signing each member's traffic with a key bound to their principal. The tenant credential it already holds keeps signing traffic with no member actor. +5. The console deletes the keys the tenant no longer uses, through the existing delete route. -Between steps 2 and 5 no key on the network works. Demo and dev tolerate that window. +No step takes the network's keys out of service. Until the console moves a member to a principal-bound key, that member's access is whatever their old key carries and no policy applies to it. A policy edit reaches a member only after step 4 has run for them. ## Alternatives considered @@ -428,9 +434,17 @@ Storing a tenant-to-key delegation and re-delegating from the key per request wo Ingot embeds versitygw, whose policy engine is disabled today by returning the admin role for 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 gateway, and leaves Hilt unable to issue delegations that match the decision. -### Keeping the parent RFC's keys alongside +### Separate creation routes per kind -An access model per tenant, `scoped-keys` or `iam`, would let existing tenants keep their keys until the console moved them. It leaves two authorization paths per request and two meanings for a key indefinitely. The tenants it would protect are demo and dev. +`POST /tenants/{tenantId}/service-credentials` and `POST /tenants/{tenantId}/principals/{userId}/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 with an optional `principalId` 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 `scoped-keys` or `iam`, and Hilt would apply one 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 worker key and a member key at once under that rule, and the console holds both from the moment it creates its first principal. + +### A permission list on a principal-bound key + +A principal-bound key could carry its own permission list, as an AWS session policy does, and be authorized against the intersection of that list and the policy result. A member could then hold a read-only key and a full key on the same buckets, and the console would not have to model the difference as two principals. It puts a second document in the path of every denial, so a 403 has two possible sources and Ingot's cached set is no longer the principal's effective set. Rejecting the fields with 422 keeps one answer to what a member may do. ### Filtering the bucket listing @@ -447,7 +461,8 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac ## Evaluation criteria - The time from a change's acknowledgement to the gateway's first refusal, measured against a warm key. This is the staleness bound the ADR asks Hilt to publish. -- Authorization latency for a service credential is unchanged from today's tenant-wide key. +- Authorization latency for a service key is unchanged from today's key. +- 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 record per principal whose access changed, and no delegation writes. @@ -474,17 +489,22 @@ CREATE TABLE principal ( PRIMARY KEY (tenant_id, external_id) ); --- The migration aborts if any access_key row remains: `hilt migrate iam` --- publishes their revocations and deletes them first. +-- A key with no principal is the parent RFC's key, authorized from its own +-- permissions and buckets. A key with one carries no authority of its own and is +-- authorized from the bucket policies, so it holds neither. Existing rows have no +-- principal and need no backfill. ALTER TABLE access_key - DROP COLUMN permissions, - DROP COLUMN buckets, ADD COLUMN principal TEXT, -- console userId - ADD COLUMN service BOOLEAN NOT NULL DEFAULT FALSE, ADD FOREIGN KEY (tenant_id, principal) REFERENCES principal(tenant_id, external_id) ON DELETE RESTRICT, - ADD CONSTRAINT access_key_kind CHECK (service <> (principal IS NOT NULL)); --- name keeps its NOT NULL and UNIQUE (tenant_id, name); a service credential stores its accessKeyId there. - + ADD CONSTRAINT access_key_principal_unscoped + CHECK (principal IS NULL OR (permissions = '{}' AND COALESCE(buckets, '{}') = '{}')); + +-- 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 IS NULL; CREATE UNIQUE INDEX access_key_principal_name_idx ON access_key (tenant_id, principal, name) WHERE principal IS NOT NULL; @@ -508,17 +528,17 @@ CREATE UNIQUE INDEX bucket_policy_principal_wildcard_idx ON bucket_policy_princi CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (tenant_id, principal, bucket_id); ``` -The `delegation` table is unchanged. The vault gains `/tenant/{tenant}/service-credential/{accessKeyId}` and nothing for principals. +The `delegation` table is unchanged, and so is the vault: every key keeps the parent RFC's `/tenant/{tenant}/access/{accessKeyId}`, and a principal has no entry. ### Example flows #### Migrate an existing tenant -1. `hilt migrate iam` has already published revocations for the tenant's keys and deleted them. -2. Fil One calls `POST /tenants/{tenantId}/service-credentials`. Hilt mints the credential, issues its powerline delegations, and returns it. -3. Fil One calls `PUT /tenants/{tenantId}/principals/{userId}` for every member. -4. Hilt stores each principal. -5. Fil One writes each bucket's policy. +1. The tenant's keys keep working as service keys. Fil One changes nothing about them and keeps using the tenant credential it holds. +2. Fil One calls `PUT /tenants/{tenantId}/principals/{userId}` for every member. +3. Hilt stores each principal. +4. Fil One writes each bucket's policy. +5. On a member's next request, Fil One calls `POST /tenants/{tenantId}/access-keys` with that member's `principalId`, signs their traffic with the returned key, and deletes the key they held before. #### Grant a member a bucket @@ -527,7 +547,7 @@ The `delegation` table is unchanged. The vault gains `/tenant/{tenant}/service-c 3. Hilt compares effective sets before and after for every named principal. The added member gained actions, so Hilt locks the policy row, invokes `/principal/invalidate` on Swarf for them, and commits the document. The response carries the new `ETag`. 4. Swarf's firehose delivers the `principal` event and Ingot drops every cached store for the member's keys. The member's next request on `photos` reaches Hilt and is authorized against the new policy. -#### Put an object with an access key +#### 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`. From f61f474c6b6cb8d7d0235929925b0bf955366d5b Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Tue, 15 Sep 2026 14:02:41 +0200 Subject: [PATCH 07/40] rfc: clear gateway caches with a per-key marker revoked through /ucan/revoke A principal-bound key now holds one tenant-issued marker delegation that grants nothing and sits in no proof chain. A policy change revokes each affected key's marker through Swarf's existing /ucan/revoke and issues a new one, so Swarf's service and Ingot's firehose consumer need no change. This replaces the /principal/invalidate command, the principal firehose event, the publisher allowlist, and Ingot's index by tenant and principal. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 186 ++++++++++------------------ 1 file changed, 67 insertions(+), 119 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 6634928..58d1914 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -24,7 +24,7 @@ On Forge the ADR settles three things: each member is a principal at the storage 1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `Allow` minus `Deny`, with an explicit `Deny` winning. 2. A policy edit changes what every key bound to a named principal may do, with no key reissued. -3. Every change to a principal's access is published to Swarf before Hilt acknowledges it, and Ingot's firehose consumer clears the gateway's warm caches within firehose latency. +3. Every change to a principal's access is published to Swarf as a revocation before Hilt acknowledges it, and Ingot's existing firehose consumer clears the gateway's warm caches within firehose latency. 4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps a service key for traffic that has no member actor. 5. One policy model governs every principal. Existing keys keep working, and a tenant moves to principals with no key reissued and no window in which the network refuses requests. @@ -36,7 +36,7 @@ The parent RFC's roles apply. Swarf is added: | Name | Description | | ----- | ---------------------------------------------------------------------------------------------------------- | -| Swarf | The Forge revocation service. Hilt publishes revocations and principal invalidations to it; Ingot consumes its firehose. | +| Swarf | The Forge revocation service. Hilt publishes revocations to it; Ingot consumes its firehose. | ### Terms @@ -44,7 +44,7 @@ The parent RFC's roles apply. Swarf is added: - **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. The policy is created with its bucket and destroyed with it. - **Principal-bound key.** An S3 access key that authenticates a request and identifies a principal. It holds no authority of its own. - **Service key.** An access key with no principal: the parent RFC's key, carrying the permissions and buckets it was created with. It signs traffic that has no member actor, such as tenant setup, bucket create and delete, and background workers. A tenant may hold several. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. -- **Principal invalidation.** The Swarf record that tells every gateway to forget the proofs it cached for a principal's keys. Publishing it is how a policy change reaches the gateway. +- **Marker.** The one delegation a principal-bound key holds: issued by the tenant to the key, naming a command nothing executes, and returned with every authorize response so the gateway's store for the key contains it. Revoking it is how a policy change reaches the gateway. ## Hilt - Tenant API @@ -185,7 +185,21 @@ A bucket created over S3 with a service key has no policy, and no principal reac } ``` -Hilt MUST generate and store the key exactly as the parent RFC specifies, and MUST record the principal on the key row. It MUST NOT issue any delegation to the key, and MUST store an empty permission set and no bucket list. 422 on a `principalId` that is not a principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `userId` in place of `permissions` and `buckets`. +Hilt MUST generate and store the key exactly as the parent RFC specifies, MUST record the principal on the key row, and MUST store an empty permission set and no bucket list. 422 on a `principalId` that is not a principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `userId` in place of `permissions` and `buckets`. + +Hilt MUST also issue the key one delegation, its marker, and store it in the `delegation` table with the tenant's other grants: + +```jsonc +{ + "iss": "did:plc:tenant", + "aud": "did:key:accessKey", + "sub": "did:plc:tenant", + "cmd": "/s3/key/marker", + // exp: the key's expiresAt, or none +} +``` + +The marker grants nothing. No service handles its command and it is a hop in no proof chain, so Sprue and Piri never see it. It exists so that the key has a delegation with a CID the gateway holds and Hilt can revoke. A fresh nonce gives each marker its own CID. A service key's name stays unique within the tenant, as today. A principal-bound key's name is unique within its principal, so two members may each hold a key named `laptop`. @@ -193,31 +207,31 @@ The console holds one such key per member for its own traffic, minted on the mem `GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. A key without it is a service key and carries its own `permissions` and `buckets`. The key list does not carry each key's access; the console reads a principal's access once through the principal route instead of once per key. -`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` deletes either kind. For a principal-bound key it MUST publish a principal invalidation before deleting the key (see [principal invalidation](#policy-changes-and-principal-invalidation)), because the key holds no delegation of its own that a revocation could name. For a service key it publishes revocations for the key's delegations, as the parent RFC specifies. +`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` deletes either kind in the parent RFC's sequence: a revocation for each of the key's delegations, then the vault entry, the delegations, and the row. For a principal-bound key the only delegation is the marker, so the gateway drops that key's store and no other. Both kinds count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. -### Policy changes and principal invalidation +### Policy changes and marker rotation -A policy `PUT` or `DELETE` changes the effective set of the principals it names, `*` expanding to every principal of the tenant. Hilt MUST compute, for each principal named in the old or new document, whether its effective set on the bucket changed, and MUST publish a principal invalidation for each such principal before acknowledging the write. Deleting one of a principal's keys and removing a principal each publish one invalidation for that principal. +A policy `PUT` or `DELETE` changes the effective set of the principals it names, `*` expanding to every principal of the tenant. For each principal named in the old or new document, Hilt MUST determine whether its effective set on the bucket changed. Before acknowledging the write, it MUST rotate the marker of every key bound to a changed principal: publish a revocation for the current marker, signed by the tenant, then issue and store a new one. -The gateway caches proof chains and effective action sets per access key until the next UTC midnight. Nothing in those chains identifies the principal, so no delegation revocation can clear them. The invalidation record names the principal directly (see [Swarf - principal invalidation](#swarf---principal-invalidation)), and the gateway drops every store it holds for that principal's keys. A widening needs the record as much as a narrowing: the gateway refuses an action outside a cached set on its own, so a grant would otherwise wait for the cache to expire. +The gateway caches proof chains and effective action sets per access key until the next UTC midnight, and it drops a key's whole store when any delegation in it is revoked. Nothing in the chains identifies the principal, and the per-request delegations are not stored, so neither gives Hilt a CID to revoke. The marker gives it one. Every authorize response for the key carries it, so every store the gateway holds for the key contains it, and one revocation clears them all. A widening needs the rotation as much as a narrowing: the gateway refuses an action outside a cached set on its own, so a grant would otherwise wait for the cache to expire. -Hilt MUST publish inside the transaction that commits the change, after locking the rows the change writes and before committing them. `/s3/request/authorize` reads the bucket's policy row, the key row, and the principal row with `SELECT ... FOR SHARE`, so an authorize request that arrives between the publish and the commit waits for the commit and is answered from the new state. Without the lock, a gateway that had consumed the record could refill its cache from the old policy and hold it until midnight. Authorize requests for a bucket wait one Swarf round trip during a write to that bucket's policy. The lock does not cover an authorize answered before the lock was taken whose response reaches the gateway after the record does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. +Hilt MUST publish inside the transaction that commits the change, after locking the policy row and the rows of the keys whose markers it rotates, and before committing. `/s3/request/authorize` reads the bucket's policy row, the key row, and the principal row with `SELECT ... FOR SHARE`. An authorize request that arrives between the publish and the commit therefore waits for the commit and is answered from the new policy with the new marker. Without the lock, a gateway that had consumed the revocation could refill its cache from the old policy, holding a marker already revoked, and keep it until midnight. Authorize requests for a bucket wait one Swarf round trip per rotated key during a write to that bucket's policy; the publishes MAY run concurrently. The lock does not cover an authorize answered before the lock was taken whose response reaches the gateway after the revocation does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. -If publishing fails, Hilt MUST return 500 and MUST NOT commit the change. A retry publishes again. A failed change leaves the principal with its old access and never with more. A record published for a change that did not commit is harmless: the gateway refills from the state Hilt holds. +If any publish fails, Hilt MUST return 500 and MUST NOT commit the change. A retry rotates again. A failed change leaves the principal with its old access and never with more. A revocation published for a rotation that did not commit leaves the old marker in service and revoked at Swarf, and a gateway may refill a store with it before the retry. The retry revokes that marker again, and Swarf records the repeat as a new event because every revocation Hilt publishes carries a fresh nonce (see [Swarf](#swarf)), so the gateway clears the store once more. -Deleting a key publishes one invalidation for the key's principal; the principal's other keys lose their cached proofs too and refill on their next request. +Deleting a key revokes only that key's marker. The principal's other keys keep their cached proofs. -The staleness bound for a change is the time from Hilt's acknowledgement to the gateway consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The record clears gateway caches and nothing else: a per-request delegation the gateway already holds in flight stays valid at Sprue and Piri until its expiry, because those services check revocation by delegation CID and the record names none. +The staleness bound for a change is the time from Hilt's acknowledgement to the gateway consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The revocation clears gateway caches and nothing else: a per-request delegation the gateway already holds in flight stays valid at Sprue and Piri until its expiry, because those services check revocation by delegation CID and the marker is in none of their chains. ### Principal removal `DELETE /tenants/{tenantId}/principals/{userId}` MUST lock the principal row for the whole removal and, in order: -1. Publish a principal invalidation. +1. Publish a revocation for the marker of each of the principal's keys. 2. Remove the principal from every statement naming it, deleting statements left with no principal and policies left with no statement. -3. Delete the principal's keys: vault entries and rows. +3. Delete the principal's keys: markers, vault entries, and rows. 4. Delete the principal row and commit, releasing the lock. Steps 2 and 3 are idempotent. A failure before step 4 rolls back the principal row and leaves the principal with narrower access; the call is retried and answers 204 once the principal is gone. An authorize request for the principal waits on the lock until the removal commits or rolls back. @@ -226,13 +240,13 @@ A removed member who is later re-invited gets a new principal row. No statement ### Bucket deletion -`/s3/bucket/delete` deletes the bucket's policy with the bucket. There is nothing to revoke: principals hold no delegations, and per-request delegations are not stored. Hilt publishes nothing. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. This holds because a Forge network runs one gateway: Ingot's registry and caches are local to the instance, and a second gateway serving the same network would keep serving the bucket from its own caches until they expired. A network with several gateways needs a bucket invalidation record, which this RFC does not define (see [open questions](#open-questions)). +`/s3/bucket/delete` deletes the bucket's policy with the bucket. Hilt revokes a service key's delegations over the bucket and publishes nothing for principal-bound keys: a marker names no bucket, and per-request delegations are not stored. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. This holds because a Forge network runs one gateway: Ingot's registry and caches are local to the instance, and a second gateway serving the same network would keep serving the bucket from its own caches until they expired. A network with several gateways needs a bucket invalidation record, which this RFC does not define (see [open questions](#open-questions)). Deleting a tenant cascades over principals, policies, and access keys. As today, it publishes no revocations and deactivates the tenant's `did:plc`. ## Hilt - UCAN API -The commands and their argument types are unchanged. The result types gain one field, and the authorization steps gain a branch on whether the key has a principal. +The commands, their argument types, and their result types keep their shapes. The authorization steps gain a branch on whether the key has a principal, and the response container for a principal-bound key gains the key's marker. ### `/s3/request/authorize` @@ -259,43 +273,15 @@ For a request signed with a principal-bound key, Hilt performs the parent RFC's } ``` -The expiry rule is the parent RFC's. Per-request delegations are returned in the response container and are not stored. Hilt already loads the tenant key to provision buckets, so signing with it adds no vault read the request path does not have today. - -**Result.** `AuthorizeOK` gains an optional `principal` field, and `permissions` carries the key's effective actions on the addressed bucket: - -```ipldsch -type AuthorizeOK struct { - bucket optional String # bucket DID; absent for ListBuckets and CreateBucket - tenant String # tenant DID - principal optional String # principal userId; absent for a service key - permissions { String: [String] } # access key DID → effective actions on `bucket` - keys { String: [VerificationKey] } - delegations { String: [Link] } -} -``` - -
-Go syntax - -```go -type AuthorizeOK struct { - Bucket *did.DID `cborgen:"bucket,omitempty"` - Tenant did.DID `cborgen:"tenant"` - Principal *string `cborgen:"principal,omitempty"` - Permissions s3.PermissionSet `cborgen:"permissions"` - Keys s3.KeySet `cborgen:"keys"` - Delegations s3.ProofSet `cborgen:"delegations"` -} -``` -
+The expiry rule is the parent RFC's. The response container carries the per-request delegations, which are not stored, and the key's marker. Hilt already loads the tenant key to provision buckets, so signing with it adds no vault read the request path does not have today. -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. `tenant` and `principal` together identify the principal, and are what the gateway indexes its caches by. +**Result.** The parent RFC's `AuthorizeOK`, with `permissions` carrying the key's effective actions on the addressed bucket. 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 is the bucket root alone, bucket → tenant with `cmd: "/"`. Neither the principal nor the key is a hop. +For a principal-bound key the chain is the bucket root alone, bucket to tenant with `cmd: "/"`, and the container carries the key's marker with it. Neither the principal nor the key is a hop. -`InfoOK` gains the same optional `principal` field, and `permissions` carries the key's effective actions on the bucket. A key whose principal cannot reach the bucket receives `UnknownBucket`. +`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` @@ -309,65 +295,19 @@ Unchanged. The listing is the tenant's whole bucket set, as AWS lists names the No new failure names. `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. Ingot's existing mapping renders them as `NoSuchBucket` (404) and `AccessDenied` (403). -## Swarf - principal invalidation +## Swarf -Swarf gains one command and one firehose event. Both carry a principal identity and no delegation. +Swarf's service needs no change. A marker rotation and a principal-bound key's deletion publish `/ucan/revoke` for the marker, self-signed by the tenant that issued it, with no witness path. That is the form of every revocation Hilt publishes. Swarf validates the marker's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. `GET /revocation/:cid` answers for a marker as for any delegation. Only gateways ever hold one, so no other verifier asks. -### `/principal/invalidate` - -* Issuer: Hilt, as its service identity (`did:web:auth.`) -* Audience: Swarf -* Subject: Hilt, the issuer's own DID - -Records that every proof a gateway cached for the principal's keys is void. - -#### Arguments - -```ipldsch -type InvalidateArguments struct { - tenant String # tenant DID - principal String # userId, unique within the tenant -} -``` - -
-Go syntax - -```go -type InvalidateArguments struct { - Tenant did.DID `cborgen:"tenant"` - Principal string `cborgen:"principal"` -} -``` -
- -#### Result - -A unit result (`{}`). - -#### Authorization - -There is no delegation to the principal, so no witness path can prove the invoker's authority over it. Hilt self-signs the invocation with its own DID as subject, the shape `/ucan/revoke` already uses, so a standard UCAN validator accepts it without proofs. Swarf MUST accept the command only from issuers in a configured publisher list, and MUST refuse it from anyone else. The list holds the Hilt service identities of the networks Swarf serves. - -### Firehose - -`GET /revocations/:from` emits a second event kind alongside `revocation`: - -``` -id: bafyreif5fzax7oygfafacvxq2ndhtkshz2av5m42hqeixea7giirdxe5dm -event: principal -data: {"tenant":"did:plc:tenant","principal":"8f2c...","cause":{"/":"bafyreif5fz..."},"recorded_at":"2026-09-09T10:00:00Z"} -``` - -The cursor, the inclusive resume rule, and deduplication by `cause` are the same as for revocation events. `GET /revocation/:cid` is unaffected: the record revokes no CID, and a verifier that checks proofs by CID never sees it. +Swarf's client library builds each revocation invocation without a nonce, so a repeat for the same delegation has the same CID and Swarf's insert ignores it. Marker rotation relies on a repeat recording a new event (see [policy changes](#policy-changes-and-marker-rotation)), so `Publish` gains an option to set a nonce, and Hilt sets a fresh one on every revocation it publishes. ## Ingot - S3 API Ingot's request path is unchanged: authorize locally from cache when it can, and ask Hilt otherwise. -**Ingot MUST enforce the effective action set.** Today Ingot discards the `permissions` field and authorizes locally by checking that its cache holds a proof chain for each Forge command the operation needs. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone therefore cannot enforce a `Deny` on one of them, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so an invalidation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. The two bucket-configuration reads map to no Forge command and are authorized at Hilt on every request, as every bucket-level operation is today. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. Ingot serves the value from its registry once Hilt answers. +**Ingot MUST enforce the effective action set.** A proof-chain probe alone cannot. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A probe cannot enforce a `Deny` on one action of such a pair, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so a revocation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. The two bucket-configuration reads map to no Forge command and are authorized at Hilt on every request, as every bucket-level operation is today. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. Ingot serves the value from its registry once Hilt answers. -**The firehose consumer gains a branch.** It drops every per-key store containing a revoked CID today. On a `principal` event it MUST drop every store for access keys whose authorize result carried the event's tenant and principal, which requires Ingot to index its per-key stores by that pair. Dropping the proof stores is enough; the derived signing keys may stay, since a request with a signing key and no chain goes to Hilt. Like the existing revoker, the handler is idempotent and an event matching nothing is a no-op. A service key's authorize result carries no principal, so no `principal` event ever matches it. Its store is cleared by the revocation path it uses today. +**The firehose consumer needs no change.** It drops every per-key store containing a revoked CID, and clears the key's derived signing key and tenant with it. Ingot adds every delegation an authorize response carries to the key's store, chain member or not, so each store for a principal-bound key contains the key's marker and a revocation of the marker drops the store. Ingot MUST keep adding every delegation the container carries. A store holding only chain members would hold nothing a rotation could name. The handler is idempotent and a revocation matching nothing is a no-op. A service key's store is cleared by the same path it uses today. Error mapping needs no change: `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. @@ -404,8 +344,8 @@ Retention and legal-hold writes pass through the API like any other action. The Every existing key becomes a service key, because a key with no principal is exactly the parent RFC's key. Per Forge network: -1. Deploy Swarf with the publisher list, and Ingot with the effective-action check and the `principal` event branch, on every gateway serving the network. -2. Deploy Hilt. Its schema migration adds the `principal` column and its indexes. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. +1. Deploy Ingot with the effective-action check on every gateway serving the network. Swarf needs no deployment. +2. Deploy Hilt. Its schema migration adds the `principal` column and its indexes. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its marker and nothing is backfilled. 3. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{userId}`, then writes a policy per bucket naming the org's Owners and Admins. 4. The console flips the region's registry entry to `iam` and starts signing each member's traffic with a key bound to their principal. The tenant credential it already holds keeps signing traffic with no member actor. 5. The console deletes the keys the tenant no longer uses, through the existing delete route. @@ -414,21 +354,29 @@ No step takes the network's keys out of service. Until the console moves a membe ## Alternatives considered +### A principal invalidation record + +Swarf could gain a `/principal/invalidate` command and a `principal` firehose event carrying a tenant and a `userId`, and Ingot's consumer a branch that drops every store 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 stores by tenant and principal, and one key's deletion would clear every key the principal holds. The marker costs a few more records per change and changes neither Swarf nor Ingot's consumer. + +### One marker per principal + +A single tenant-issued marker per principal, carried in every response for the principal's keys, would clear all of them with one revocation and cost one row per principal. It needs a new issue-and-rotate path in Hilt and a stored delegation bound to no key, and it clears every key of the principal when one is deleted. Per key, the same rows and revocations fall out of the paths a service key already uses, at the keys-per-principal factor in publishes. + ### Allow statements materialized as stored delegations -Hilt could write one tenant-to-principal delegation per bucket and Forge command that an Allow grants, and revoke exactly those on a narrowing. Stored authority would then equal granted authority, and revocation would name the precise delegation. It costs principals times buckets times commands rows, turns an Owner promotion into one delegation set per bucket, and still cannot express `Deny`, which would be enforced at Hilt and Ingot regardless. One invalidation record per principal produces the same cache effect from no rows. +Hilt could write one tenant-to-principal delegation per bucket and Forge command that an Allow grants, and revoke exactly those on a narrowing. Stored authority would then equal granted authority, and revocation would name the precise delegation. It costs principals times buckets times commands rows, turns an Owner promotion into one delegation set per bucket, and still cannot express `Deny`, which would be enforced at Hilt and Ingot regardless. One marker per key produces the same cache effect from one row per key. ### Recorded per-request delegations revoked on narrowing -Hilt could persist each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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 gateway drops the whole per-key store on any revoked CID anyway, so the precision changes nothing at the cache. +Hilt could persist each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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 gateway drops the whole per-key store on any revoked CID anyway, so the precision changes nothing at the cache. Revoking them would also cut in-flight work at Sprue and Piri, on a widening as on a narrowing. ### A did:key principal with a standing delegation -Each principal could be an ed25519 key Hilt stores, holding one powerline delegation from the tenant that sits in every chain for the principal's keys. A narrowing would then rotate that delegation and revoke the old one by CID through Swarf's existing command, so Swarf and Ingot's consumer would change nothing and every downstream verifier would honor the revocation. A did:key principal could also 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 rotation on every narrowing. The invalidation record trades all of that for a signal only gateways act on. +Each principal could be an ed25519 key Hilt stores, holding one powerline delegation from the tenant that sits in every chain for the principal's keys. A narrowing would then rotate that delegation and revoke the old one by CID through Swarf's existing command, so Swarf and Ingot's consumer would change nothing and every downstream verifier would honor the revocation. A did:key principal could also 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 rotation on every narrowing. The marker keeps the rotation and drops the vault entry and the hop. It sits in no chain, so only gateways act on its revocation. ### The access key as a proof-chain hop -Storing a tenant-to-key delegation and re-delegating from the key per request would mirror today's mechanism, let a key deletion revoke one CID, and clear the gateway through the existing revocation path. It adds a hop to every chain and gives the key a delegation of its own, which the ADR's model says it does not have, and a policy edit would still have nothing to revoke. +Storing a tenant-to-key delegation and re-delegating from the key per request would mirror today's mechanism, let a key deletion revoke one CID, and clear the gateway through the existing revocation path. It adds a hop to every chain and makes the key's delegation carry authority, which the ADR's model says it does not. ### Policies evaluated at the gateway @@ -453,10 +401,9 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac ## Open questions 1. What latency target does `GET /principals/{userId}/access` carry? The console resolves it per request for its bucket list and activity feed. It is an index lookup at Hilt; the number is unmeasured. -2. A policy edit naming `*` publishes one invalidation per principal of the tenant. There is no figure for principals per tenant on Forge. If it is large, that fan-out is the cost to watch. -3. Swarf trusts the publisher list and checks no proof. Whether Swarf should verify that a tenant owns the named principal, for example by consulting Hilt, is open. -4. Limits on statements per policy and principals per statement. None are specified here. -5. Bucket invalidation for a network with several gateways. Bucket deletion clears only the gateway that handled it, and Ingot's registry is per instance, so such a network needs a record the firehose carries and a registry the gateways share. +2. A policy edit naming `*` rotates the marker of every key of every principal of the tenant, one Swarf round trip each inside the write's transaction. There is no figure for principals per tenant or keys per principal on Forge. The publishes can run concurrently. If the product is large, that fan-out is the cost to watch. +3. Limits on statements per policy and principals per statement. None are specified here. +4. Bucket invalidation for a network with several gateways. Bucket deletion clears only the gateway that handled it, and Ingot's registry is per instance, so such a network needs a record the firehose carries and a registry the gateways share. ## Evaluation criteria @@ -464,7 +411,8 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac - Authorization latency for a service key is unchanged from today's key. - 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 record per principal whose access changed, and no delegation writes. +- A policy `PUT` costs one Swarf revocation and one stored delegation per key of each principal whose access changed. +- Swarf's service and Ingot's firehose consumer need no changes. ## References @@ -528,7 +476,7 @@ CREATE UNIQUE INDEX bucket_policy_principal_wildcard_idx ON bucket_policy_princi CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (tenant_id, principal, bucket_id); ``` -The `delegation` table is unchanged, and so is the vault: every key keeps the parent RFC's `/tenant/{tenant}/access/{accessKeyId}`, and a principal has no entry. +The `delegation` table keeps its schema; a principal-bound key's one row in it is its marker. The vault keeps the parent RFC's layout: every key at `/tenant/{tenant}/access/{accessKeyId}`, and no entry for a principal. ### Example flows @@ -544,26 +492,26 @@ The `delegation` table is unchanged, and so is the vault: every key keeps the pa 1. Fil One reads `GET /tenants/{t}/buckets/photos/policy` and its `ETag`. 2. Fil One writes the document with an added `Allow` statement, sending `If-Match`. -3. Hilt compares effective sets before and after for every named principal. The added member gained actions, so Hilt locks the policy row, invokes `/principal/invalidate` on Swarf for them, and commits the document. The response carries the new `ETag`. -4. Swarf's firehose delivers the `principal` event and Ingot drops every cached store for the member's keys. The member's next request on `photos` reaches Hilt and is authorized against the new policy. +3. Hilt compares effective sets before and after for every named principal. The added member gained actions, so Hilt locks the policy row and the member's key rows, publishes a revocation for each key's marker, stores a new marker per key, and commits the document. The response carries the new `ETag`. +4. Swarf's firehose delivers the revocations and Ingot drops the store of every key that held one of the markers. The member's next request on `photos` reaches Hilt and is authorized against the new policy. #### 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 tenant-to-gateway delegations for the six write commands with the tenant key, expiring at the next UTC midnight. -4. Ingot invokes `/s3/bucket/info` for the bucket root, caches the chains and the effective set under the key, indexed by tenant and principal, and serves the request. +3. Hilt verifies the signature, loads the key's principal, resolves `photos`, computes `effective(principal, photos)`, finds `s3:PutObject`, and signs tenant-to-gateway delegations for the six write commands with the tenant key, expiring at the next UTC midnight. It returns them with the key's marker. +4. Ingot invokes `/s3/bucket/info` for the bucket root, caches the chains, the marker, 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 writes the policy without the member's statement, with `If-Match`. -2. Hilt finds the member's effective set on the bucket shrank, locks the policy row, invokes `/principal/invalidate` on Swarf for the member, and commits the document. An authorize request for `photos` that arrives meanwhile waits for the commit. -3. Swarf's firehose delivers the `principal` event. Ingot drops every cached store for the member's keys. +2. Hilt finds the member's effective set on the bucket shrank, locks the policy row and the member's key rows, publishes a revocation for each key's marker, stores new markers, 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 store of every key that held one of the markers. 4. The member's next request on any bucket reaches Hilt. On the removed bucket it receives `UnknownBucket`, which Ingot renders as `NoSuchBucket`. #### Remove a member 1. Fil One calls `DELETE /tenants/{t}/principals/{userId}`. -2. Hilt locks the principal row, invokes `/principal/invalidate` on Swarf, strips the principal from every statement, deletes its keys, and deletes the principal row. -3. Ingot drops the member's cached stores on the `principal` event. Their keys no longer resolve at Hilt. +2. Hilt locks the principal row, publishes a revocation for each of the member's key markers, strips the principal from every statement, deletes its keys and markers, and deletes the principal row. +3. Ingot drops each key's store on its revocation. The keys no longer resolve at Hilt. From 90dfa779e4656497f1be92f6d96a4774e517eabe Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Wed, 16 Sep 2026 14:52:53 +0200 Subject: [PATCH 08/40] rfc: address review, define terms and add policy on bucket create Terms are defined before use: Ingot replaces "the gateway", member and effective actions are defined, and the marker rotation spells out what is published, in which transaction, under which locks. Bucket policies take a flat AWS-like shape with a bare "*" principal wildcard and s3:*, principals are addressed by principalId, principal-bound keys store NULL permissions and buckets, and removed principals are tombstoned. A new bucket's policy travels in the signed x-bucket-policy header on CreateBucket and is written with the bucket in one transaction. Claims corrected against the code: Sprue and Piri do not check revocations, Ingot's bucket registry is shared across instances, Ingot already caches the effective action set, the vault path is /tenant/{tenant}/access-key/{key}, and Hilt serves several regions. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 337 ++++++++++++++++------------ 1 file changed, 193 insertions(+), 144 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 58d1914..a1052ff 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -22,10 +22,10 @@ On Forge the ADR settles three things: each member is a principal at the storage ## Goals -1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `Allow` minus `Deny`, with an explicit `Deny` winning. +1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `allow` minus `deny`, with an explicit `deny` winning. 2. A policy edit changes what every key bound to a named principal may do, with no key reissued. -3. Every change to a principal's access is published to Swarf as a revocation before Hilt acknowledges it, and Ingot's existing firehose consumer clears the gateway's warm caches within firehose latency. -4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps a service key for traffic that has no member actor. +3. Every change to a principal's effective actions is published to Swarf as a revocation before Hilt acknowledges it, and Ingot's existing firehose consumer clears Ingot's caches within firehose latency. +4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps service keys for traffic that has no member actor. 5. One policy model governs every principal. Existing keys keep working, and a tenant moves to principals with no key reissued and no window in which the network refuses requests. ## Concepts @@ -34,40 +34,42 @@ On Forge the ADR settles three things: each member is a principal at the storage The parent RFC's roles apply. Swarf is added: -| Name | Description | -| ----- | ---------------------------------------------------------------------------------------------------------- | +| Name | Description | +| ----- | -------------------------------------------------------------------------------------------- | | Swarf | The Forge revocation service. Hilt publishes revocations to it; Ingot consumes its firehose. | ### Terms -- **Principal.** A member as Hilt knows them: `(tenant, userId)` and nothing more. A principal carries no permissions, no role, and no key material. -- **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. The policy is created with its bucket and destroyed with it. -- **Principal-bound key.** An S3 access key that authenticates a request and identifies a principal. It holds no authority of its own. -- **Service key.** An access key with no principal: the parent RFC's key, carrying the permissions and buckets it was created with. It signs traffic that has no member actor, such as tenant setup, bucket create and delete, and background workers. A tenant may hold several. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. -- **Marker.** The one delegation a principal-bound key holds: issued by the tenant to the key, naming a command nothing executes, and returned with every authorize response so the gateway's store for the key contains it. Revoking it is how a policy change reaches the gateway. +- **Member.** A user of a Fil One organization. Fil One gives each member one of the roles Owner, Admin, Member, or ReadOnly. Hilt knows none of them. +- **Principal.** A member as Hilt knows them: `(tenant, principalId)` and nothing more. The console creates one principal per member. A principal carries no permissions, no role, and no key material. +- **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. A bucket has at most one policy, and the policy 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 carries the permissions and buckets it was created with. It signs traffic that has no member actor, such as tenant setup, bucket create and delete, and background workers. A tenant may hold several. +- **Principal-bound key.** An S3 access key that authenticates a request and names a principal. It holds no authority of its own: what it may do is whatever the bucket policies give its principal at the time of the request. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. +- **Marker.** A delegation Hilt issues to each principal-bound key. It names a command no service handles and grants nothing. Hilt returns it in every `/s3/request/authorize` response, so the store Ingot caches for the key always contains it, and revoking it through Swarf is how a policy change clears that store. ## Hilt - Tenant API -`POST /tenants/{tenantId}/access-keys` gains one optional field, `principalId`, and that field decides which kind of key it creates: +`POST /tenants/{tenantId}/access-keys` creates both kinds of key. The body takes one of two shapes, and the shape decides the kind: -| `principalId` | Kind | Authority | -| ------------- | ------------------- | --------------------------------------------------------------------------- | -| absent | Service key | The `permissions` and `buckets` in the request, as the parent RFC specifies. | -| present | Principal-bound key | Whatever the bucket policies give the principal, evaluated per request. | +| 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, evaluated on each request. | -Hilt MUST reject with 422 a request carrying `principalId` together with a non-empty `permissions` or `buckets`. A caller that asks for a narrowed key would get one whose reach the policies decide, and neither the create response nor a later `GET` would show the difference. The management API requires `permissions` on every create today. That requirement applies only to a create without `principalId`. +Hilt MUST reject with 422 a body carrying `principalId` together with `permissions` or `buckets`. A principal-bound key has no permissions of its own, and accepting the fields would suggest otherwise. The management API's rule that `permissions` is required applies to the service-key shape only. `GET`, `DELETE`, and the tenant's key list serve both kinds. ### Tenant creation -`PUT /tenants/{tenantId}` is unchanged. The console creates the tenant and then calls `POST /tenants/{tenantId}/access-keys` with no `principalId` for the credential it provisions buckets with, as it does today. +`PUT /tenants/{tenantId}` is unchanged. Its body names the region, and Hilt binds the tenant to the provider that serves it. One Hilt serves several regions; a tenant serves one. The console creates the tenant and then creates a service key with no bucket list for provisioning buckets, as it does today. -A Forge network serves one region, and a tenant is unique to its region, so the external tenant id stays the organization's UUID as the contract defines it. Hilt's one-provider-per-tenant model holds unchanged. +Fil One uses the organization's id as the tenant id. An organization served in two regions of the same Hilt would need two tenants, and this RFC gives them no id. Multi-region tenants are [FIL-1133](https://linear.app/filecoin-foundation/issue/FIL-1133). Nothing below depends on a tenant serving one region. ### Service keys -A service key is the parent RFC's access key, unchanged: an ed25519 `did:key` at the vault path `/tenant/{tenant}/access/{accessKeyId}`. It holds one delegation from the tenant per bucket and Forge command mapped from its permissions, or a powerline delegation with an undefined subject when it was created with no bucket list. +A service key is the parent RFC's access key, unchanged: an ed25519 `did:key` whose private key is stored at the vault path `/tenant/{tenantDID}/access-key/{accessKeyDID}`. It holds one delegation from the tenant per bucket and Forge command mapped from its permissions, or a powerline delegation with an undefined subject when it was created with no bucket list. A service key: @@ -78,69 +80,79 @@ A service key: Its bucket list is fixed at creation, so a service key scoped to named buckets cannot reach a bucket created afterwards. Tenant setup, bucket create, and bucket delete therefore use a key with no bucket list. -Deleting a service key MUST publish revocations for its delegations before deleting the vault entry and the row, as the parent RFC's key delete does. +Deleting a service key publishes a revocation for each of its delegations, then deletes the delegations, the row, and the vault entry, as the parent RFC's key delete does. ### Principals Routes, all under `/tenants/{tenantId}/principals` and authenticated with the partner key: -| Method | Path | Purpose | -| ------ | ------------------------------------ | ------------------------------------------------------- | -| PUT | `/principals/{userId}` | Create the principal; 200 if it exists. Idempotent. | -| GET | `/principals` | List principals. | -| GET | `/principals/{userId}` | Principal detail. | -| DELETE | `/principals/{userId}` | Remove the principal (see [removal](#principal-removal)). 204 if already gone. | -| GET | `/principals/{userId}/policies` | Every bucket policy with a statement naming the principal or `*`. | -| GET | `/principals/{userId}/access` | The principal's effective actions per bucket. | -| GET | `/principals/{userId}/access-keys` | List the principal's keys. | +| 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. | +| GET | `/principals/{principalId}/access-keys` | List the principal's keys. | A key bound to a principal is created at the tenant's access-key route with `principalId` set (see [principal-bound access keys](#principal-bound-access-keys)). -`userId` is an opaque string supplied by the caller, unique within the tenant. +`principalId` is an opaque string supplied by the caller, unique within the tenant. Hilt rejects with 422 an empty id, an id longer than 255 characters, and the id `*`. A principal is represented as: ```jsonc { - "userId": "8f2c...", + "principalId": "8f2c...", "createdAt": "2026-09-09T10:00:00Z" } ``` -Creating a principal stores the row and nothing else. A principal has no DID, no vault entry, and no delegation. It appears in no UCAN: Hilt evaluates the bucket policy for the principal and signs the per-request delegation with the tenant key, so the proof chain runs from the bucket to the tenant to the gateway. +Creating a principal stores the row and nothing else. A principal has no DID, no vault entry, and no delegation, and it appears in no UCAN. On each request Hilt evaluates the bucket's policy for the principal and signs the per-request delegations with the tenant key, so the proof chain runs from the bucket to the tenant to Ingot. -The principal's effective actions are read through `GET /principals/{userId}/access`: +Ingot caches what Hilt answers for a principal-bound key exactly as it does for a service key: the proof chains, the effective actions on the addressed bucket, the derived signing key, and the tenant, until the next UTC midnight (see [Ingot](#ingot---s3-api)). Hilt is consulted on a cache miss and after a revocation. A warm key's requests go from Ingot to Sprue and Piri with no Hilt round trip. The designs that model principals or policies as stored UCAN delegations instead are in [alternatives 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:GetObject", "s3:PutObject", "s3:DeleteObject"] } + { "name": "backups", "actions": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"] } ] } ``` -Buckets with an empty effective set are omitted. The read is computed from Hilt's store on every call and is consistent with Hilt's last write. It makes no network call. +Buckets with an empty effective set are omitted. Hilt computes the answer from its own tables on every call and makes no call to another service, so the read is consistent with Hilt's last write. + +A removed principal is kept as a tombstone (see [removal](#principal-removal)). It is absent from `GET` and the list, holds no keys, is named in no statement, and a `PUT` for its id revives it with nothing attached. ### Bucket policies A bucket has at most one policy, addressed by the bucket's name: -| Method | Path | Purpose | -| ------ | --------------------------------------------- | --------------------------------------------------------- | -| GET | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Read the policy. Returns an `ETag`. | -| PUT | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Create or replace. Carries `If-Match` or `If-None-Match: *`. | -| DELETE | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Delete. Carries `If-Match`. | +| Method | Path | Purpose | +| ------ | ------------------------------------------------- | -------------------------------------------------------------- | +| GET | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Read the policy. Returns an `ETag`. | +| PUT | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Create or replace. Carries `If-None-Match: *` or `If-Match`. | +| DELETE | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Delete. Carries `If-Match`. | -The document: +The document follows the shape of an AWS bucket policy, reduced to what Forge evaluates: ```jsonc { - "statements": [ + "statement": [ + { + "sid": "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": "Allow", // "Allow" | "Deny" - "principals": ["8f2c...", "a91e..."], // userIds, or ["*"] for every principal of the tenant - "actions": ["s3:GetObject", "s3:ListBucket"] + "effect": "deny", + "principal": "*", + "action": ["s3:PutObjectRetention", "s3:PutObjectLegalHold"] } ] } @@ -148,30 +160,37 @@ The document: Hilt MUST reject with 422: -- an action outside the [policy vocabulary](#action-vocabulary); in particular `s3:CreateBucket`, `s3:DeleteBucket`, and `s3:ListAllMyBuckets`, -- a `userId` that is not a principal of the tenant, -- an empty `statements` list (the caller deletes the policy instead). +- 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. The caller deletes the policy instead, +- 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 404 on all three routes. +A bucket that does not exist or belongs to another tenant is 404 on all three routes. `GET` and `DELETE` on a bucket that has no policy are 404 as well, with a `PolicyNotFound` body that tells the two cases apart. -Hilt MUST accept a `Deny` naming `*`. The management API carries no actor; the console decides whether to write one. +Hilt MUST accept a `deny` statement whose `principal` is `"*"`. The management API carries no actor; the console decides whether to write one. **Evaluation.** The effective actions of principal `p` on bucket `b` are ``` -effective(p, b) = union(actions of Allow statements naming p or *) - \ union(actions of Deny statements naming p or *) +effective(p, b) = union(action of allow statements naming p or "*") + \ union(action of deny statements naming p or "*") ``` -A bucket with no policy has an empty effective set for every principal. `s3:ListAllMyBuckets` is outside the per-bucket set: every principal holds it, and `ListBuckets` is authorized without consulting any policy (see [action vocabulary](#action-vocabulary)). A service key is not a principal and is not evaluated against policies. +where `s3:*` stands for every action in the policy vocabulary. Hilt returns the set sorted. A bucket with no policy has an empty effective set for every principal. A service key is not a principal and no policy is evaluated for it. + +`s3:ListAllMyBuckets` is outside the per-bucket set: every principal holds it. The S3 `ListBuckets` operation, which lists the tenant's bucket names and needs that action, is therefore authorized without reading any policy. It is a different operation from listing the objects in one bucket, which needs `s3:ListBucket` and is granted like any other action. + +**Compare-and-set.** `GET` returns an `ETag` computed over Hilt's canonical encoding of the stored document. `PUT` and `DELETE` MUST carry exactly one precondition: `If-Match` with that value, or, on a `PUT` that creates the first policy, `If-None-Match: *`. A request with neither, with both, or with another `If-None-Match` value is 400. A mismatch, or `If-None-Match: *` when a policy exists, is 412 and nothing is written. A successful `PUT` answers 201 when it created the policy and 200 when it replaced one, with the new `ETag` in the response header and no body. Callers treat the ETag as opaque. -**Compare-and-set.** `GET` returns a strong `ETag` computed over Hilt's canonical encoding of the stored document. `PUT` and `DELETE` MUST carry `If-Match` with that value; a mismatch is 412 and nothing is written. A `PUT` creating the first policy carries `If-None-Match: *` and is 412 if a policy already exists. Callers treat the ETag as opaque. A successful `PUT` returns the new `ETag`. +**Storage.** Hilt stores the document with its bucket and maintains an index from principal to the buckets whose statements name it, so the two principal reads above cost an index lookup. Statements naming `"*"` are indexed under the tenant. The policy row is deleted with the bucket. -**Storage.** Hilt stores the document with its bucket and maintains an index from principal to the buckets whose statements name it, so the two principal reads above cost an index lookup. Statements naming `*` are indexed under the tenant. The policy row is deleted with the bucket. +### Bucket creation -### Buckets without a policy +Buckets are created and deleted over S3 with a service key, and only with a service key. A bucket policy grants actions on the bucket it is stored on; `CreateBucket` acts on a bucket that does not exist yet and `DeleteBucket` on the bucket whose policy would have to grant it, so no policy can carry either. A principal-bound key receives `AccessDenied` from both. `aws s3 mb` with a member's key therefore fails, and members create buckets through the console. -A bucket created over S3 with a service key has no policy, and no principal reaches it until one is written. The console creates a bucket over S3 and writes its policy with `If-None-Match: *` before answering the member, so a member never learns of a bucket they cannot open. A console failure between the two calls leaves a bucket only service keys reach. The console writes the policy on its retry, since a repeated `CreateBucket` for a name the tenant owns is reported as already owned. +A new bucket's policy travels on the create request, in the `x-bucket-policy` header: the policy document as JSON, base64-encoded. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header needs nothing from Ingot. Hilt decodes it, validates it exactly as `PUT .../policy` would, and writes it in the transaction that creates the bucket, so a bucket and its policy exist together or not at all. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps a request head at 8 KB, which leaves roughly 5 KB of JSON for the document. + +The console sends the header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written through the management API. ### Principal-bound access keys @@ -185,7 +204,7 @@ A bucket created over S3 with a service key has no policy, and no principal reac } ``` -Hilt MUST generate and store the key exactly as the parent RFC specifies, MUST record the principal on the key row, and MUST store an empty permission set and no bucket list. 422 on a `principalId` that is not a principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `userId` in place of `permissions` and `buckets`. +Hilt MUST generate and store the key exactly as the parent RFC specifies, MUST record the principal on the key row, and MUST store `NULL` for its permissions and its bucket list. 422 on a `principalId` that is not a live principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. Hilt MUST also issue the key one delegation, its marker, and store it in the `delegation` table with the tenant's other grants: @@ -199,54 +218,58 @@ Hilt MUST also issue the key one delegation, its marker, and store it in the `de } ``` -The marker grants nothing. No service handles its command and it is a hop in no proof chain, so Sprue and Piri never see it. It exists so that the key has a delegation with a CID the gateway holds and Hilt can revoke. A fresh nonce gives each marker its own CID. +The marker grants nothing. No service handles its command and it is a hop in no proof chain, so Sprue and Piri never see it. It exists so that the key holds a delegation whose CID Ingot caches and Hilt can revoke. Every delegation carries a random nonce, so each marker has its own CID. A principal-bound key that has no marker is refused with `UnknownAccessKey`. A service key's name stays unique within the tenant, as today. A principal-bound key's name is unique within its principal, so two members may each hold a key named `laptop`. -The console holds one such key per member for its own traffic, minted on the member's first request in the region, and signs that member's bucket listing, object listing, object reads and deletes, and presigned URLs with it. Hilt does not distinguish it from a key the member created: it is bound to the same principal, authorized against the same policies, and deleted with the principal. A presigned URL signed with it is authorized against the member's policies when it is redeemed, with the parent RFC's signature time bounds and the key's own expiry applying as for any request. +The console holds two kinds of key. Its service keys sign traffic that has no member actor. For each member it holds one principal-bound key, minted on the member's first request, and signs that member's bucket listing, object listing, object reads and deletes, and presigned URLs with it. Hilt does not distinguish that key from one the member created themselves: both are bound to the same principal, authorized against the same policies, and deleted with the principal. A presigned URL signed with it is authorized against the member's policies when it is redeemed, with the parent RFC's signature time bounds and the key's own expiry applying as for any request. -`GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. A key without it is a service key and carries its own `permissions` and `buckets`. The key list does not carry each key's access; the console reads a principal's access once through the principal route instead of once per key. +`GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. A key without it is a service key and carries its own `permissions` and `buckets`. 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 parent RFC's sequence: a revocation for each of the key's delegations, then the vault entry, the delegations, and the row. For a principal-bound key the only delegation is the marker, so the gateway drops that key's store and no other. +`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` deletes either kind in the same sequence: a revocation for each of the key's delegations, then the delegations, the row, and the vault entry. For a principal-bound key the only delegation is the marker, so Ingot drops that key's store and no other. Both kinds count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. ### Policy changes and marker rotation -A policy `PUT` or `DELETE` changes the effective set of the principals it names, `*` expanding to every principal of the tenant. For each principal named in the old or new document, Hilt MUST determine whether its effective set on the bucket changed. Before acknowledging the write, it MUST rotate the marker of every key bound to a changed principal: publish a revocation for the current marker, signed by the tenant, then issue and store a new one. +A policy `PUT` or `DELETE` changes the effective actions of the principals it names, with `"*"` standing for every live principal of the tenant. For each principal named in the old or the new document, Hilt compares the effective set before and after. For every key bound to a principal whose set changed, Hilt rotates the marker before acknowledging the write: it publishes a revocation of the current marker, then issues and stores a new one. To publish a revocation is to send Swarf a `/ucan/revoke` invocation for the delegation, signed by the tenant that issued it. A widening rotates as much as a narrowing, because Ingot refuses an action outside a cached set on its own and a grant would otherwise wait for the cache to expire at midnight. + +The marker is what makes the rotation possible. Ingot's store for a key holds the proof chains, the effective actions, and the marker, and Ingot drops the whole store when any delegation in it is revoked. Nothing else in the store names something Hilt could revoke: the chains do not identify the principal, and the per-request delegations are issued fresh and never stored at Hilt. Every authorize response carries the marker, so every store Ingot holds for the key contains it, and one revocation clears them all. -The gateway caches proof chains and effective action sets per access key until the next UTC midnight, and it drops a key's whole store when any delegation in it is revoked. Nothing in the chains identifies the principal, and the per-request delegations are not stored, so neither gives Hilt a CID to revoke. The marker gives it one. Every authorize response for the key carries it, so every store the gateway holds for the key contains it, and one revocation clears them all. A widening needs the rotation as much as a narrowing: the gateway refuses an action outside a cached set on its own, so a grant would otherwise wait for the cache to expire. +**Locking.** Hilt takes no row locks today; this RFC adds them on two paths. A policy write runs in one Postgres transaction that locks the policy row with `SELECT ... FOR UPDATE` and takes a transaction-scoped advisory lock on the bucket. Each marker rotation runs in a transaction that holds an advisory lock on that key's delegation set. Hilt publishes the revocations while those locks are held and commits afterwards. `/s3/request/authorize` reads the key row, the principal row, and the policy row with `SELECT ... FOR SHARE`, and reads the key's marker before the policy. An authorize request that arrives between the publish and the commit therefore waits for the commit and is answered from the new policy with the new marker. Without the locks, an Ingot that had consumed the revocation could refill its cache from the old policy, holding a marker already revoked, and keep it until midnight. -Hilt MUST publish inside the transaction that commits the change, after locking the policy row and the rows of the keys whose markers it rotates, and before committing. `/s3/request/authorize` reads the bucket's policy row, the key row, and the principal row with `SELECT ... FOR SHARE`. An authorize request that arrives between the publish and the commit therefore waits for the commit and is answered from the new policy with the new marker. Without the lock, a gateway that had consumed the revocation could refill its cache from the old policy, holding a marker already revoked, and keep it until midnight. Authorize requests for a bucket wait one Swarf round trip per rotated key during a write to that bucket's policy; the publishes MAY run concurrently. The lock does not cover an authorize answered before the lock was taken whose response reaches the gateway after the revocation does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. +Authorize requests for a bucket wait one Swarf round trip per rotated key during a write to that bucket's policy; the publishes MAY run concurrently. Waits are bounded. A reader or writer that cannot take its lock within 10 seconds fails, and the publishes of one write are given 8 seconds together, so a slow Swarf releases the locks before the readers time out. A management write that times out is 409 `ConcurrentChange`; an authorize that times out is `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). The locks do not cover an authorize answered before the write took its locks whose response reaches Ingot after the revocation does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. -If any publish fails, Hilt MUST return 500 and MUST NOT commit the change. A retry rotates again. A failed change leaves the principal with its old access and never with more. A revocation published for a rotation that did not commit leaves the old marker in service and revoked at Swarf, and a gateway may refill a store with it before the retry. The retry revokes that marker again, and Swarf records the repeat as a new event because every revocation Hilt publishes carries a fresh nonce (see [Swarf](#swarf)), so the gateway clears the store once more. +**Failure.** If Swarf refuses or fails a publish, Hilt answers 500 and commits nothing. The old policy stays in force, and the principal never holds more than it did. The old marker is now revoked at Swarf while still in service at Hilt, so Ingot drops the key's store and, on the key's next request, refills it from the old policy with the old marker. The console retries the write. The retry revokes the old marker again, and Swarf records the repeat as a new event because every revocation Hilt publishes carries a fresh nonce (see [Swarf](#swarf)), so Ingot clears the store once more. A console that stops retrying leaves the old policy in force and nothing half-applied; the next write to that policy, from any caller, rotates the markers again. Deleting a key revokes only that key's marker. The principal's other keys keep their cached proofs. -The staleness bound for a change is the time from Hilt's acknowledgement to the gateway consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The revocation clears gateway caches and nothing else: a per-request delegation the gateway already holds in flight stays valid at Sprue and Piri until its expiry, because those services check revocation by delegation CID and the marker is in none of their chains. +The staleness bound for a change is the time from Hilt's acknowledgement to Ingot consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The revocation clears Ingot's caches and nothing else. Sprue and Piri do not consult Swarf, so a per-request delegation Ingot already holds stays valid at them until it expires. Only Ingot can use it: Ingot is the delegation's audience and signs every invocation that carries it, and once the revocation has cleared its cache it asks Hilt, which answers from the new policy. ### Principal removal -`DELETE /tenants/{tenantId}/principals/{userId}` MUST lock the principal row for the whole removal and, in order: +`DELETE /tenants/{tenantId}/principals/{principalId}` locks the principal row with `SELECT ... FOR UPDATE` for the whole removal and, in order: -1. Publish a revocation for the marker of each of the principal's keys. -2. Remove the principal from every statement naming it, deleting statements left with no principal and policies left with no statement. -3. Delete the principal's keys: markers, vault entries, and rows. -4. Delete the principal row and commit, releasing the lock. +1. Publishes a revocation for the marker of each of the principal's keys. +2. Removes the principal from every statement naming it, writing each policy with its current `ETag`. A statement left with no principal is deleted, and a policy left with no statement is deleted. +3. Deletes the principal's keys: rows, then vault entries. +4. Sets `deleted_at` on the principal row and commits, releasing the lock. -Steps 2 and 3 are idempotent. A failure before step 4 rolls back the principal row and leaves the principal with narrower access; the call is retried and answers 204 once the principal is gone. An authorize request for the principal waits on the lock until the removal commits or rolls back. +The row stays as a tombstone. 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 on the lock until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. -A removed member who is later re-invited gets a new principal row. No statement survives to restore their old access. +A failure before step 4 rolls back the transaction and leaves the principal with narrower access. The revocations already published stand, and Ingot refills from the unchanged policies on the key's next request. The console retries; 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 clears `deleted_at`. 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's policy with the bucket. Hilt revokes a service key's delegations over the bucket and publishes nothing for principal-bound keys: a marker names no bucket, and per-request delegations are not stored. The gateway removes the bucket from its registry on the same request, and its local authorization refuses any bucket it does not know, so cached chains for the deleted bucket cannot be used. This holds because a Forge network runs one gateway: Ingot's registry and caches are local to the instance, and a second gateway serving the same network would keep serving the bucket from its own caches until they expired. A network with several gateways needs a bucket invalidation record, which this RFC does not define (see [open questions](#open-questions)). +`/s3/bucket/delete` deletes the bucket's delegations, its policy, and the bucket row. Hilt revokes a service key's delegations over the bucket and publishes nothing for principal-bound keys: a marker names no bucket, and per-request delegations are not stored. Ingot removes the bucket from its registry on the same request. The registry is a Postgres table shared by every Ingot instance serving the network, and Ingot's local authorization refuses any bucket the registry does not know, so cached chains for the deleted bucket cannot be used on any instance once the row is gone. Deleting a tenant cascades over principals, policies, and access keys. As today, it publishes no revocations and deactivates the tenant's `did:plc`. ## Hilt - UCAN API -The commands, their argument types, and their result types keep their shapes. The authorization steps gain a branch on whether the key has a principal, and the response container for a principal-bound key gains the key's marker. +The commands, their argument types, and their result types keep their shapes. The authorization steps gain a branch on whether the key has a principal, the response container for a principal-bound key gains the key's marker, and bucket creation gains the policy header. ### `/s3/request/authorize` @@ -255,27 +278,27 @@ For a request signed with a service key, the parent RFC's steps apply unchanged: 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. +2. Load the key's marker and its principal. A key with no marker, or 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`. +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. Issue one per-request delegation per Forge command mapped from the action, signed with the tenant key: ```jsonc { "iss": "did:plc:tenant", - "aud": "did:web:gateway", // the invocation issuer + "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 } ``` -The expiry rule is the parent RFC's. The response container carries the per-request delegations, which are not stored, and the key's marker. Hilt already loads the tenant key to provision buckets, so signing with it adds no vault read the request path does not have today. +The expiry rule is the parent RFC's. The response container carries the per-request delegations, which are not stored, and the key's marker. The key, principal, and policy rows are read with a share lock (see [policy changes](#policy-changes-and-marker-rotation)). Signing with the tenant key reads the tenant's private key where the parent RFC's path reads the access key's, so the request path costs one vault read either way. -**Result.** The parent RFC's `AuthorizeOK`, with `permissions` carrying the key's effective actions on the addressed bucket. 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. +**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` @@ -285,56 +308,63 @@ For a principal-bound key the chain is the bucket root alone, bucket to tenant w ### `/s3/bucket/create` and `/s3/bucket/delete` -Unchanged for a service key. It reaches them by holding `s3:CreateBucket` or `s3:DeleteBucket` in its permissions. A principal-bound key receives `OperationNotPermitted` from both. Bucket deletion additionally removes the policy, as described above. +A service key reaches them by holding `s3:CreateBucket` or `s3:DeleteBucket` in its permissions. A principal-bound key receives `OperationNotPermitted` from both. + +Create reads the `x-bucket-policy` header from the forwarded request when it is present, checks that the header is signed, validates the decoded document as `PUT .../policy` does, and stores it with the bucket in one transaction (see [bucket creation](#bucket-creation)). An unsigned or invalid document refuses the create with `InvalidBucketPolicy`. Delete removes the policy with the bucket, as described above. ### `/s3/bucket/list` -Unchanged. The listing is the tenant's whole bucket set, as AWS lists names the caller cannot open. The console filters its own list against the principal's access. +Unchanged. The listing is the tenant's whole bucket set, as AWS lists names the caller cannot open. The console filters its own list against the principal's effective actions. ### Failures -No new failure names. `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. Ingot's existing mapping renders them as `NoSuchBucket` (404) and `AccessDenied` (403). +`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 with no marker or a removed principal. Ingot's existing mapping renders them as `NoSuchBucket` (404), `AccessDenied` (403), and `InvalidAccessKeyId` (403). + +Two failures are new. `TemporarilyUnavailable` is an authorize that could not take its share lock within the lock timeout; Ingot renders it as `ServiceUnavailable` (503) and the client retries. `InvalidBucketPolicy` is a create whose policy header is unsigned or fails validation; Ingot renders it as `InvalidArgument` (400). ## Swarf -Swarf's service needs no change. A marker rotation and a principal-bound key's deletion publish `/ucan/revoke` for the marker, self-signed by the tenant that issued it, with no witness path. That is the form of every revocation Hilt publishes. Swarf validates the marker's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. `GET /revocation/:cid` answers for a marker as for any delegation. Only gateways ever hold one, so no other verifier asks. +Swarf's service needs no change. A marker rotation and a principal-bound key's deletion publish `/ucan/revoke` for the marker, a UCAN command Swarf serves on its RPC endpoint, self-signed by the tenant that issued the marker, with no witness path. That is the form of every revocation Hilt publishes. Swarf validates the marker's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. `GET /revocation/:cid` answers for a marker as for any delegation. Only Ingot ever holds one, so no other verifier asks. -Swarf's client library builds each revocation invocation without a nonce, so a repeat for the same delegation has the same CID and Swarf's insert ignores it. Marker rotation relies on a repeat recording a new event (see [policy changes](#policy-changes-and-marker-rotation)), so `Publish` gains an option to set a nonce, and Hilt sets a fresh one on every revocation it publishes. +Swarf's client library builds each revocation invocation without a nonce, so a repeat for the same delegation has the same CID and Swarf's insert ignores it. Marker rotation relies on a repeat recording a new event (see [policy changes](#policy-changes-and-marker-rotation)), so `Publish` gains a `WithNonce` option, and Hilt sets a fresh 16-byte nonce on every revocation it publishes. ## Ingot - S3 API Ingot's request path is unchanged: authorize locally from cache when it can, and ask Hilt otherwise. -**Ingot MUST enforce the effective action set.** A proof-chain probe alone cannot. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A probe cannot enforce a `Deny` on one action of such a pair, or a policy that grants one without the other. Ingot MUST cache the `permissions` value per access key and bucket, in the same per-key store as the chains so a revocation drops both, and MUST refuse a fast-path request whose action is not in the cached set. A request for a bucket with no cached set goes to Hilt. The two bucket-configuration reads map to no Forge command and are authorized at Hilt on every request, as every bucket-level operation is today. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. Ingot serves the value from its registry once Hilt answers. +Ingot caches, per access key, what Hilt's authorize response carries: the delegations, the effective action set under each bucket Hilt named, the derived SigV4 verification key, and the tenant, all until the next UTC midnight plus clock skew. The fast path verifies the signature with the cached key, requires the action to be in the cached set for the bucket, and requires a chain to Ingot's agent for every Forge command the action maps to. Anything less goes to Hilt, whose answer refills the caches. The cached set is what makes `deny` enforceable. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone could not refuse one action of such a pair, or honor a policy that grants one without the other; the set does. 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`, and the two bucket-configuration reads. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. Ingot serves the two configuration reads from its registry once Hilt answers. **The firehose consumer needs no change.** It drops every per-key store containing a revoked CID, and clears the key's derived signing key and tenant with it. Ingot adds every delegation an authorize response carries to the key's store, chain member or not, so each store for a principal-bound key contains the key's marker and a revocation of the marker drops the store. Ingot MUST keep adding every delegation the container carries. A store holding only chain members would hold nothing a rotation could name. The handler is idempotent and a revocation matching nothing is a no-op. A service key's store is cleared by the same path it uses today. -Error mapping needs no change: `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. +Error mapping gains two rows: `TemporarilyUnavailable` renders as `ServiceUnavailable` (503) and `InvalidBucketPolicy` as `InvalidArgument` (400). `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. ## Action vocabulary -The policy vocabulary is Hilt's S3 permission set without the three bucket-level actions, plus the two bucket-configuration reads the console already offers on a key: - -| 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` | as `s3:PutObject` | | -| `s3:PutObjectLegalHold` | as `s3:PutObject` | | -| `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:GetBucketVersioning` | none | new; served by Ingot from its registry | -| `s3:GetBucketObjectLockConfiguration` | none | new; served by Ingot from its registry | - -Excluded from policies: `s3:CreateBucket` and `s3:DeleteBucket`, because a principal holding them acts outside the policy that granted it, and `s3:ListAllMyBuckets`, which every principal holds. A service key still holds the two bucket actions through its own permissions, which is how the console creates and deletes buckets. +The policy vocabulary is Hilt's S3 permission set without the three bucket-level actions, plus the two bucket-configuration reads the console already offers on a key, 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` | as `s3:PutObject` | | +| `s3:PutObjectLegalHold` | as `s3:PutObject` | | +| `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:GetBucketVersioning` | none | new; served by Ingot from its registry | +| `s3:GetBucketObjectLockConfiguration` | none | new; served by Ingot from its registry | +| `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, and `s3:ListAllMyBuckets`, which every principal holds. `s3:*` never expands to any of the three. A service key still holds the two bucket actions through its own permissions, which is how the console creates and deletes buckets. The two bucket-configuration reads are new to Hilt. Its operation classifier MUST recognize `GET /{bucket}?versioning` as `GetBucketVersioning` and `GET /{bucket}?object-lock` as `GetBucketObjectLockConfiguration`; today both classify as `ListBucket`. The management API's action enum, now used by policy statements, gains both, along with `s3:AbortMultipartUpload` and `s3:ListMultipartUploadParts`, which Hilt already accepts. @@ -344,10 +374,10 @@ Retention and legal-hold writes pass through the API like any other action. The Every existing key becomes a service key, because a key with no principal is exactly the parent RFC's key. Per Forge network: -1. Deploy Ingot with the effective-action check on every gateway serving the network. Swarf needs no deployment. -2. Deploy Hilt. Its schema migration adds the `principal` column and its indexes. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its marker and nothing is backfilled. -3. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{userId}`, then writes a policy per bucket naming the org's Owners and Admins. -4. The console flips the region's registry entry to `iam` and starts signing each member's traffic with a key bound to their principal. The tenant credential it already holds keeps signing traffic with no member actor. +1. Deploy Ingot with the two new error mappings. Ingot already enforces the cached effective action set, and Swarf's service needs no deployment; Hilt takes the client library with the nonce option. +2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its marker and nothing is backfilled. +3. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{principalId}`, then writes a policy per bucket naming the org's Owners and Admins. +4. The console flips the registry entry to `iam`, starts signing each member's traffic with a key bound to their principal, and sends `x-bucket-policy` on every bucket it creates. The service key it already holds keeps signing traffic with no member actor. 5. The console deletes the keys the tenant no longer uses, through the existing delete route. No step takes the network's keys out of service. Until the console moves a member to a principal-bound key, that member's access is whatever their old key carries and no policy applies to it. A policy edit reaches a member only after step 4 has run for them. @@ -356,35 +386,35 @@ No step takes the network's keys out of service. Until the console moves a membe ### A principal invalidation record -Swarf could gain a `/principal/invalidate` command and a `principal` firehose event carrying a tenant and a `userId`, and Ingot's consumer a branch that drops every store 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 stores by tenant and principal, and one key's deletion would clear every key the principal holds. The marker costs a few more records per change and changes neither Swarf nor Ingot's consumer. +Swarf could gain a `/principal/invalidate` command and a `principal` firehose event carrying a tenant and a `principalId`, and Ingot's consumer a branch that drops every store 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 stores by tenant and principal, and one key's deletion would clear every key the principal holds. The marker costs a few more records per change and changes neither Swarf nor Ingot's consumer. ### One marker per principal A single tenant-issued marker per principal, carried in every response for the principal's keys, would clear all of them with one revocation and cost one row per principal. It needs a new issue-and-rotate path in Hilt and a stored delegation bound to no key, and it clears every key of the principal when one is deleted. Per key, the same rows and revocations fall out of the paths a service key already uses, at the keys-per-principal factor in publishes. -### Allow statements materialized as stored delegations +### `allow` statements materialized as stored delegations -Hilt could write one tenant-to-principal delegation per bucket and Forge command that an Allow grants, and revoke exactly those on a narrowing. Stored authority would then equal granted authority, and revocation would name the precise delegation. It costs principals times buckets times commands rows, turns an Owner promotion into one delegation set per bucket, and still cannot express `Deny`, which would be enforced at Hilt and Ingot regardless. One marker per key produces the same cache effect from one row per key. +Hilt could write one tenant-to-principal delegation per bucket and Forge command that an `allow` grants, and revoke exactly those on a narrowing. Stored authority would then equal granted authority, and revocation would name the precise delegation. It costs principals times buckets times commands rows, turns an Owner promotion into one delegation set per bucket, and still cannot express `deny`, which would be enforced at Hilt and Ingot regardless. One marker per key produces the same cache effect from one row per key. ### Recorded per-request delegations revoked on narrowing -Hilt could persist each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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 gateway drops the whole per-key store on any revoked CID anyway, so the precision changes nothing at the cache. Revoking them would also cut in-flight work at Sprue and Piri, on a widening as on a narrowing. +Hilt could persist each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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 Ingot drops the whole per-key store on any revoked CID anyway, so the precision changes nothing at the cache. Revoking them would also cut in-flight work at Sprue and Piri, on a widening as on a narrowing. ### A did:key principal with a standing delegation -Each principal could be an ed25519 key Hilt stores, holding one powerline delegation from the tenant that sits in every chain for the principal's keys. A narrowing would then rotate that delegation and revoke the old one by CID through Swarf's existing command, so Swarf and Ingot's consumer would change nothing and every downstream verifier would honor the revocation. A did:key principal could also 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 rotation on every narrowing. The marker keeps the rotation and drops the vault entry and the hop. It sits in no chain, so only gateways act on its revocation. +Each principal could be an ed25519 key Hilt stores, holding one powerline delegation from the tenant that sits in every chain for the principal's keys. A narrowing would then rotate that delegation and revoke the old one by CID through Swarf's existing command, so Swarf and Ingot's consumer would change nothing and every downstream verifier would honor the revocation. A did:key principal could also 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 rotation on every narrowing. The marker keeps the rotation and drops the vault entry and the hop. It sits in no chain, so only Ingot acts on its revocation. ### The access key as a proof-chain hop -Storing a tenant-to-key delegation and re-delegating from the key per request would mirror today's mechanism, let a key deletion revoke one CID, and clear the gateway through the existing revocation path. It adds a hop to every chain and makes the key's delegation carry authority, which the ADR's model says it does not. +Storing a tenant-to-key delegation and re-delegating from the key per request would mirror today's mechanism, let a key deletion revoke one CID, and clear Ingot through the existing revocation path. It adds a hop to every chain and makes the key's delegation carry authority, which the ADR's model says it does not. -### Policies evaluated at the gateway +### Policies evaluated at Ingot -Ingot embeds versitygw, whose policy engine is disabled today by returning the admin role for 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 gateway, and leaves Hilt unable to issue delegations that match the decision. +Ingot embeds versitygw, whose policy engine is disabled today by returning the admin role for 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/{userId}/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 with an optional `principalId` leaves the console's existing calls working and keeps the parent RFC's authorization path in place for traffic that has no member actor. +`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 @@ -394,24 +424,31 @@ A tenant could carry `scoped-keys` or `iam`, and Hilt would apply one model to e A principal-bound key could carry its own permission list, as an AWS session policy does, and be authorized against the intersection of that list and the policy result. A member could then hold a read-only key and a full key on the same buckets, and the console would not have to model the difference as two principals. It puts a second document in the path of every denial, so a 403 has two possible sources and Ingot's cached set is no longer the principal's effective set. Rejecting the fields with 422 keeps one answer to what a member may do. +### A default policy template per tenant + +Hilt could hold one policy document per tenant and copy it onto every bucket at create, so the create request would carry nothing. The console would have to keep the template in step with the organization's roster, which is a second copy of the same statements it writes on every bucket, and the template could not name the member who created the bucket. The header carries the same document once, on the request that needs it. + +### The policy written after the create + +The console could create the bucket over S3 and then write its policy through the management API, answering the member after both calls. A console failure between the two calls, or after the retry of the second, leaves a bucket that appears in the listing and that no member can open, with nothing to trigger another write. Carrying the policy on the create makes the two one. + ### Filtering the bucket listing -Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for access keys in Hilt PR #48 and closed as a divergence from AWS, where a listing shows names the caller cannot open. The console filters its own list, and the gateway's behavior is unchanged. +Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for access keys in Hilt PR #48 and closed as a divergence from AWS, where a listing shows 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/{userId}/access` carry? The console resolves it per request for its bucket list and activity feed. It is an index lookup at Hilt; the number is unmeasured. -2. A policy edit naming `*` rotates the marker of every key of every principal of the tenant, one Swarf round trip each inside the write's transaction. There is no figure for principals per tenant or keys per principal on Forge. The publishes can run concurrently. If the product is large, that fan-out is the cost to watch. -3. Limits on statements per policy and principals per statement. None are specified here. -4. Bucket invalidation for a network with several gateways. Bucket deletion clears only the gateway that handled it, and Ingot's registry is per instance, so such a network needs a record the firehose carries and a registry the gateways share. +1. What latency target does `GET /principals/{principalId}/access` carry? The console resolves it per request for its bucket list and activity feed. It is an index lookup at Hilt; the number is unmeasured. +2. A policy edit naming `"*"` rotates the marker of every key of every principal of the tenant, one Swarf round trip each inside the write's transaction. There is no figure for principals per tenant or keys per principal on Forge. The publishes run concurrently within the 8-second budget. If the product is large, that fan-out is the cost to watch. +3. Limits on statements per policy and principals per statement. None are specified here beyond the 8 KB request head on a create. ## Evaluation criteria -- The time from a change's acknowledgement to the gateway's first refusal, measured against a warm key. This is the staleness bound the ADR asks Hilt to publish. +- 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 from today's key. - 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 stored delegation per key of each principal whose access changed. +- A policy `PUT` costs one Swarf revocation and one stored delegation per key of each principal whose effective actions changed. - Swarf's service and Ingot's firehose consumer need no changes. ## References @@ -421,6 +458,7 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac - [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. +- [FIL-1133](https://linear.app/filecoin-foundation/issue/FIL-1133), multiple Forge regions per tenant. - Hilt PR #48, the closed proposal for a scoped bucket listing. ## Appendix @@ -432,20 +470,23 @@ Changes to the parent RFC's schema. Types and constraints follow the existing mi ```sql CREATE TABLE principal ( tenant_id TEXT NOT NULL REFERENCES tenant(id) ON DELETE RESTRICT, - external_id TEXT NOT NULL, -- console userId + 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 carries no authority of its own and is --- authorized from the bucket policies, so it holds neither. Existing rows have no +-- authorized from the bucket policies, so both are NULL. Existing rows have no -- principal and need no backfill. ALTER TABLE access_key - ADD COLUMN principal TEXT, -- console userId + ALTER COLUMN permissions DROP NOT NULL, + ADD COLUMN principal TEXT, -- principalId ADD FOREIGN KEY (tenant_id, principal) REFERENCES principal(tenant_id, external_id) ON DELETE RESTRICT, ADD CONSTRAINT access_key_principal_unscoped - CHECK (principal IS NULL OR (permissions = '{}' AND COALESCE(buckets, '{}') = '{}')); + CHECK ((principal IS NULL AND permissions IS NOT NULL) + OR (principal 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 @@ -463,7 +504,7 @@ CREATE TABLE bucket_policy ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Which principals a policy names; '*' statements are indexed as NULL principal. +-- Which principals a policy names; "*" statements are indexed as NULL principal. CREATE TABLE bucket_policy_principal ( bucket_id TEXT NOT NULL REFERENCES bucket(id) ON DELETE CASCADE, tenant_id TEXT NOT NULL, @@ -476,42 +517,50 @@ CREATE UNIQUE INDEX bucket_policy_principal_wildcard_idx ON bucket_policy_princi CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (tenant_id, principal, bucket_id); ``` -The `delegation` table keeps its schema; a principal-bound key's one row in it is its marker. The vault keeps the parent RFC's layout: every key at `/tenant/{tenant}/access/{accessKeyId}`, and no entry for a principal. +The `delegation` table keeps its schema; a principal-bound key's one row in it is its marker. 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 tenant credential it holds. -2. Fil One calls `PUT /tenants/{tenantId}/principals/{userId}` for every member. +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. 5. On a member's next request, Fil One calls `POST /tenants/{tenantId}/access-keys` with that member's `principalId`, signs their traffic with the returned key, and deletes the key they held before. +#### Create a bucket + +1. A member creates `photos` in the console. Fil One builds the policy naming the member and the org's Owners and Admins, and sends `PUT /photos` to Ingot signed with its service key, with the document base64-encoded in the signed `x-bucket-policy` header. +2. Ingot invokes `/s3/bucket/create` with the request. +3. Hilt verifies the signature, checks that the header is signed, decodes and validates the document, and creates the bucket and its policy in one transaction. +4. Ingot adds the bucket to its registry and answers the member. Their next request on `photos` reaches Hilt and is authorized against the policy. + #### Grant a member a bucket 1. Fil One reads `GET /tenants/{t}/buckets/photos/policy` and its `ETag`. -2. Fil One writes the document with an added `Allow` statement, sending `If-Match`. -3. Hilt compares effective sets before and after for every named principal. The added member gained actions, so Hilt locks the policy row and the member's key rows, publishes a revocation for each key's marker, stores a new marker per key, and commits the document. The response carries the new `ETag`. +2. Fil One writes the document with an added `allow` statement, sending `If-Match`. +3. Hilt compares effective sets before and after for every named principal. The added member gained actions, so Hilt locks the policy row, publishes a revocation for each of the member's key markers under each key's lock, stores a new marker per key, and commits the document. The response carries the new `ETag`. 4. Swarf's firehose delivers the revocations and Ingot drops the store of every key that held one of the markers. The member's next request on `photos` reaches Hilt and is authorized against the new policy. #### 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 tenant-to-gateway delegations for the six write commands with the tenant key, expiring at the next UTC midnight. It returns them with the key's marker. +3. Hilt verifies the signature, loads the key's marker and principal, resolves `photos`, computes `effective(principal, photos)`, finds `s3:PutObject`, and signs tenant-to-Ingot delegations for the six write commands with the tenant key, expiring at the next UTC midnight. It returns them with the key's marker. 4. Ingot invokes `/s3/bucket/info` for the bucket root, caches the chains, the marker, 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 writes the policy without the member's statement, with `If-Match`. -2. Hilt finds the member's effective set on the bucket shrank, locks the policy row and the member's key rows, publishes a revocation for each key's marker, stores new markers, and commits the document. An authorize request for `photos` that arrives meanwhile waits for the commit. +2. Hilt finds the member's effective set on the bucket shrank, locks the policy row, publishes a revocation for each of the member's key markers, stores new markers, 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 store of every key that held one of the markers. 4. The member's next request on any bucket reaches Hilt. On the removed bucket it receives `UnknownBucket`, which Ingot renders as `NoSuchBucket`. #### Remove a member -1. Fil One calls `DELETE /tenants/{t}/principals/{userId}`. -2. Hilt locks the principal row, publishes a revocation for each of the member's key markers, strips the principal from every statement, deletes its keys and markers, and deletes the principal row. +1. Fil One calls `DELETE /tenants/{t}/principals/{principalId}`. +2. Hilt locks the principal row, publishes a revocation for each of the member's key markers, strips the principal from every statement, deletes its keys, sets `deleted_at`, and commits. 3. Ingot drops each key's store on its 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. From fa323be740f30d3e53dee54687148f7573789629 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Wed, 16 Sep 2026 15:28:49 +0200 Subject: [PATCH 09/40] rfc: write the create-request policy after the bucket row The bucket store inserts a bucket in one statement and the rest of bucket creation already compensates with a rollback, so the policy carried by x-bucket-policy is written right after the bucket row and the bucket is deleted if that write fails. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index a1052ff..271f274 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -188,7 +188,7 @@ where `s3:*` stands for every action in the policy vocabulary. Hilt returns the Buckets are created and deleted over S3 with a service key, and only with a service key. A bucket policy grants actions on the bucket it is stored on; `CreateBucket` acts on a bucket that does not exist yet and `DeleteBucket` on the bucket whose policy would have to grant it, so no policy can carry either. A principal-bound key receives `AccessDenied` from both. `aws s3 mb` with a member's key therefore fails, and members create buckets through the console. -A new bucket's policy travels on the create request, in the `x-bucket-policy` header: the policy document as JSON, base64-encoded. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header needs nothing from Ingot. Hilt decodes it, validates it exactly as `PUT .../policy` would, and writes it in the transaction that creates the bucket, so a bucket and its policy exist together or not at all. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps a request head at 8 KB, which leaves roughly 5 KB of JSON for the document. +A new bucket's policy travels on the create request, in the `x-bucket-policy` header: the policy document as JSON, base64-encoded. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header needs nothing from Ingot. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, and writes the policy. If the policy write fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps a request head at 8 KB, which leaves roughly 5 KB of JSON for the document. The console sends the header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written through the management API. @@ -310,7 +310,7 @@ For a principal-bound key the chain is the bucket root alone, bucket to tenant w A service key reaches them by holding `s3:CreateBucket` or `s3:DeleteBucket` in its permissions. A principal-bound key receives `OperationNotPermitted` from both. -Create reads the `x-bucket-policy` header from the forwarded request when it is present, checks that the header is signed, validates the decoded document as `PUT .../policy` does, and stores it with the bucket in one transaction (see [bucket creation](#bucket-creation)). An unsigned or invalid document refuses the create with `InvalidBucketPolicy`. Delete removes the policy with the bucket, as described above. +Create reads the `x-bucket-policy` header from the forwarded request when it is present, checks that the header is signed, validates the decoded document as `PUT .../policy` does, creates the bucket, and stores the policy. A failed policy write deletes the bucket (see [bucket creation](#bucket-creation)). An unsigned or invalid document refuses the create with `InvalidBucketPolicy`. Delete removes the policy with the bucket, as described above. ### `/s3/bucket/list` From 93bf8b9a4eb1b24098b6ab252d39390732e084c4 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Thu, 17 Sep 2026 14:44:22 +0200 Subject: [PATCH 10/40] refactor: s3:GetBucketVersioning and s3:GetBucketObjectLockConfiguration from bucket policy --- rfcs/2026-09-forge-s3-tenant-iam.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 271f274..5a7e939 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -334,7 +334,7 @@ Ingot's request path is unchanged: authorize locally from cache when it can, and Ingot caches, per access key, what Hilt's authorize response carries: the delegations, the effective action set under each bucket Hilt named, the derived SigV4 verification key, and the tenant, all until the next UTC midnight plus clock skew. The fast path verifies the signature with the cached key, requires the action to be in the cached set for the bucket, and requires a chain to Ingot's agent for every Forge command the action maps to. Anything less goes to Hilt, whose answer refills the caches. The cached set is what makes `deny` enforceable. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone could not refuse one action of such a pair, or honor a policy that grants one without the other; the set does. 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`, and the two bucket-configuration reads. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. Ingot serves the two configuration reads from its registry once Hilt answers. +Operations that map to no Forge command are authorized at Hilt on every request: `ListBuckets`, `CreateBucket`, and `DeleteBucket`. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. **The firehose consumer needs no change.** It drops every per-key store containing a revoked CID, and clears the key's derived signing key and tenant with it. Ingot adds every delegation an authorize response carries to the key's store, chain member or not, so each store for a principal-bound key contains the key's marker and a revocation of the marker drops the store. Ingot MUST keep adding every delegation the container carries. A store holding only chain members would hold nothing a rotation could name. The handler is idempotent and a revocation matching nothing is a no-op. A service key's store is cleared by the same path it uses today. @@ -342,7 +342,7 @@ Error mapping gains two rows: `TemporarilyUnavailable` renders as `ServiceUnavai ## Action vocabulary -The policy vocabulary is Hilt's S3 permission set without the three bucket-level actions, plus the two bucket-configuration reads the console already offers on a key, plus a wildcard: +The policy vocabulary is Hilt's S3 permission set without the three bucket-level actions, plus a wildcard: | S3 action | Forge commands | Note | | ------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- | @@ -360,13 +360,11 @@ The policy vocabulary is Hilt's S3 permission set without the three bucket-level | `s3:ListBucketMultipartUploads` | `/content/retrieve` | | | `s3:ListMultipartUploadParts` | `/content/retrieve` | | | `s3:AbortMultipartUpload` | `/blob/abort`, `/blob/remove` | | -| `s3:GetBucketVersioning` | none | new; served by Ingot from its registry | -| `s3:GetBucketObjectLockConfiguration` | none | new; served by Ingot from its registry | | `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, and `s3:ListAllMyBuckets`, which every principal holds. `s3:*` never expands to any of the three. A service key still holds the two bucket actions through its own permissions, which is how the console creates and deletes buckets. -The two bucket-configuration reads are new to Hilt. Its operation classifier MUST recognize `GET /{bucket}?versioning` as `GetBucketVersioning` and `GET /{bucket}?object-lock` as `GetBucketObjectLockConfiguration`; today both classify as `ListBucket`. The management API's action enum, now used by policy statements, gains both, along with `s3:AbortMultipartUpload` and `s3:ListMultipartUploadParts`, which Hilt already accepts. +Bucket-configuration reads (`GET /{bucket}?versioning`, `GET /{bucket}?object-lock`) classify as `ListBucket`, as they do today, so `s3:ListBucket` covers them and Ingot's fast path applies. The management API's action enum, now used by policy statements, gains `s3:AbortMultipartUpload` and `s3:ListMultipartUploadParts`, which Hilt already accepts. 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. From ead57c9d8875eaa07d90314a1306f6c6fbede1a7 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Thu, 17 Sep 2026 15:38:54 +0200 Subject: [PATCH 11/40] rfc: explicitly explain including the principalId access key creation body --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 5a7e939..b96025c 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -204,6 +204,8 @@ The console sends the header on every bucket it creates. The document names the } ``` +`principalId` is provided in the request body rather than as a path parameter so that `POST /tenants/{tenantId}/access-keys` can create both service keys and principal-bound keys. This preserves the console's existing calls, while the key type is inferred from the fields supplied in the request body. A separate `POST /tenants/{tenantId}/principals/{principalId}/access-keys` route is discussed in [Alternatives considered](#separate-creation-routes-per-kind). + Hilt MUST generate and store the key exactly as the parent RFC specifies, MUST record the principal on the key row, and MUST store `NULL` for its permissions and its bucket list. 422 on a `principalId` that is not a live principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. Hilt MUST also issue the key one delegation, its marker, and store it in the `delegation` table with the tenant's other grants: From a46b6dcdeb7dea29ba712c5a4cf9ad5534c6feef Mon Sep 17 00:00:00 2001 From: Srdjan Date: Fri, 18 Sep 2026 13:18:50 +0200 Subject: [PATCH 12/40] Apply suggestion from @bajtos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Miroslav Bajtoš --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index b96025c..df146a8 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -376,7 +376,7 @@ Every existing key becomes a service key, because a key with no principal is exa 1. Deploy Ingot with the two new error mappings. Ingot already enforces the cached effective action set, and Swarf's service needs no deployment; Hilt takes the client library with the nonce option. 2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its marker and nothing is backfilled. -3. The console creates a principal for each existing member with `PUT /tenants/{tenantId}/principals/{principalId}`, then writes a policy per bucket naming the org's Owners and Admins. +3. The console creates a principal for each existing org member in every existing org with `PUT /tenants/{tenantId}/principals/{principalId}`, then writes a policy per bucket naming the org's Owners and Admins. 4. The console flips the registry entry to `iam`, starts signing each member's traffic with a key bound to their principal, and sends `x-bucket-policy` on every bucket it creates. The service key it already holds keeps signing traffic with no member actor. 5. The console deletes the keys the tenant no longer uses, through the existing delete route. From 13b203bf5157a1f3dcce134b70c763dda0552183 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 18 Sep 2026 14:38:56 +0200 Subject: [PATCH 13/40] rfc: address review, console keys, tenant removal, migration by scope Answer bajtos's second round on PR 30. Add a Fil One console section that collects the two kinds of key the console holds and what follows from per-member keys, give tenant removal its own section and specify the revocations it publishes, state that every publishing write is one transaction and that the console retries from a durable job, bound the time a write holds its transaction open, restructure the migration by scope (network, organization, region, member) and keep existing keys, replace Ingot's "registry" with its local database, simplify the firehose paragraph, and call out bucket creation as a limit of the policy model. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 64 +++++++++++++++++++---------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index df146a8..8a0ba7a 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -45,7 +45,7 @@ The parent RFC's roles apply. Swarf is added: - **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. A bucket has at most one policy, and the policy 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 carries the permissions and buckets it was created with. It signs traffic that has no member actor, such as tenant setup, bucket create and delete, and background workers. A tenant may hold several. -- **Principal-bound key.** An S3 access key that authenticates a request and names a principal. It holds no authority of its own: what it may do is whatever the bucket policies give its principal at the time of the request. Everything a member does in the console, from listing buckets and objects to deleting objects and redeeming presigned URLs, is signed with a key bound to that member's principal. +- **Principal-bound key.** An S3 access key that authenticates a request and names a principal. It holds no authority of its own: what it may do is whatever the bucket policies give its principal at the time of the request. - **Marker.** A delegation Hilt issues to each principal-bound key. It names a command no service handles and grants nothing. Hilt returns it in every `/s3/request/authorize` response, so the store Ingot caches for the key always contains it, and revoking it through Swarf is how a policy change clears that store. ## Hilt - Tenant API @@ -186,7 +186,7 @@ where `s3:*` stands for every action in the policy vocabulary. Hilt returns the ### Bucket creation -Buckets are created and deleted over S3 with a service key, and only with a service key. A bucket policy grants actions on the bucket it is stored on; `CreateBucket` acts on a bucket that does not exist yet and `DeleteBucket` on the bucket whose policy would have to grant it, so no policy can carry either. A principal-bound key receives `AccessDenied` from both. `aws s3 mb` with a member's key therefore fails, and members create buckets through the console. +Buckets are created and deleted over S3 with a service key, and only with a service key. A bucket policy grants actions on the bucket it is stored on; `CreateBucket` acts on a bucket that does not exist yet and `DeleteBucket` on the bucket whose policy would have to grant it, so no policy can carry either. A principal-bound key receives `AccessDenied` from both. `aws s3 mb` with a member's key therefore fails, and members create buckets through the console. This is a limit of the policy model, and the ADR accepts it: `CreateBucket` and `DeleteBucket` come off member keys in every region. The user-facing S3 documentation needs to state it. A new bucket's policy travels on the create request, in the `x-bucket-policy` header: the policy document as JSON, base64-encoded. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header needs nothing from Ingot. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, and writes the policy. If the policy write fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps a request head at 8 KB, which leaves roughly 5 KB of JSON for the document. @@ -204,7 +204,7 @@ The console sends the header on every bucket it creates. The document names the } ``` -`principalId` is provided in the request body rather than as a path parameter so that `POST /tenants/{tenantId}/access-keys` can create both service keys and principal-bound keys. This preserves the console's existing calls, while the key type is inferred from the fields supplied in the request body. A separate `POST /tenants/{tenantId}/principals/{principalId}/access-keys` route is discussed in [Alternatives considered](#separate-creation-routes-per-kind). +`principalId` travels in the body rather than the path so that one route, `POST /tenants/{tenantId}/access-keys`, creates both kinds of key and the console's existing calls keep working. The shape of the body decides the kind. 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, MUST record the principal on the key row, and MUST store `NULL` for its permissions and its bucket list. 422 on a `principalId` that is not a live principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. @@ -224,8 +224,6 @@ The marker grants nothing. No service handles its command and it is a hop in no A service key's name stays unique within the tenant, as today. A principal-bound key's name is unique within its principal, so two members may each hold a key named `laptop`. -The console holds two kinds of key. Its service keys sign traffic that has no member actor. For each member it holds one principal-bound key, minted on the member's first request, and signs that member's bucket listing, object listing, object reads and deletes, and presigned URLs with it. Hilt does not distinguish that key from one the member created themselves: both are bound to the same principal, authorized against the same policies, and deleted with the principal. A presigned URL signed with it is authorized against the member's policies when it is redeemed, with the parent RFC's signature time bounds and the key's own expiry applying as for any request. - `GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. A key without it is a service key and carries its own `permissions` and `buckets`. 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, then the delegations, the row, and the vault entry. For a principal-bound key the only delegation is the marker, so Ingot drops that key's store and no other. @@ -240,9 +238,11 @@ The marker is what makes the rotation possible. Ingot's store for a key holds th **Locking.** Hilt takes no row locks today; this RFC adds them on two paths. A policy write runs in one Postgres transaction that locks the policy row with `SELECT ... FOR UPDATE` and takes a transaction-scoped advisory lock on the bucket. Each marker rotation runs in a transaction that holds an advisory lock on that key's delegation set. Hilt publishes the revocations while those locks are held and commits afterwards. `/s3/request/authorize` reads the key row, the principal row, and the policy row with `SELECT ... FOR SHARE`, and reads the key's marker before the policy. An authorize request that arrives between the publish and the commit therefore waits for the commit and is answered from the new policy with the new marker. Without the locks, an Ingot that had consumed the revocation could refill its cache from the old policy, holding a marker already revoked, and keep it until midnight. -Authorize requests for a bucket wait one Swarf round trip per rotated key during a write to that bucket's policy; the publishes MAY run concurrently. Waits are bounded. A reader or writer that cannot take its lock within 10 seconds fails, and the publishes of one write are given 8 seconds together, so a slow Swarf releases the locks before the readers time out. A management write that times out is 409 `ConcurrentChange`; an authorize that times out is `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). The locks do not cover an authorize answered before the write took its locks whose response reaches Ingot after the revocation does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. +Authorize requests for a bucket wait one Swarf round trip per rotated key during a write to that bucket's policy; the publishes MAY run concurrently. A write holds its transaction open while it waits on Swarf, for at most 8 seconds: the publishes of one write share that budget, and a write that has not finished them by then fails and rolls back. A reader or writer that cannot take its lock within 10 seconds fails as well, so a slow Swarf releases the locks before any reader times out. A management write that times out is 409 `ConcurrentChange`; an authorize that times out is `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). The locks do not cover an authorize answered before the write took its locks whose response reaches Ingot after the revocation does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. + +**Failure.** If Swarf refuses or fails a publish, Hilt answers 500 and commits nothing. The old policy stays in force, and the principal never holds more than it did. The old marker is now revoked at Swarf while still in service at Hilt, so Ingot drops the key's store and, on the key's next request, refills it from the old policy with the old marker. The console retries the write. The retry revokes the old marker again, and Swarf records the repeat as a new event because every revocation Hilt publishes carries a fresh nonce (see [Swarf](#swarf)), so Ingot clears the store once more. A console that stops retrying leaves the old policy in force and nothing half-applied. The next write to that policy, from any caller, rotates the markers again. -**Failure.** If Swarf refuses or fails a publish, Hilt answers 500 and commits nothing. The old policy stays in force, and the principal never holds more than it did. The old marker is now revoked at Swarf while still in service at Hilt, so Ingot drops the key's store and, on the key's next request, refills it from the old policy with the old marker. The console retries the write. The retry revokes the old marker again, and Swarf records the repeat as a new event because every revocation Hilt publishes carries a fresh nonce (see [Swarf](#swarf)), so Ingot clears the store once more. A console that stops retrying leaves the old policy in force and nothing half-applied; the next write to that policy, from any caller, rotates the markers again. +**Recovery.** Hilt runs no reconciliation, because a failed write leaves nothing to reconcile: every write that publishes runs in one transaction, and the state Hilt stores is always the state it enforces. What a failed call leaves behind 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. Deleting a key revokes only that key's marker. The principal's other keys keep their cached proofs. @@ -250,24 +250,26 @@ The staleness bound for a change is the time from Hilt's acknowledgement to Ingo ### Principal removal -`DELETE /tenants/{tenantId}/principals/{principalId}` locks the principal row with `SELECT ... FOR UPDATE` for the whole removal and, in order: +`DELETE /tenants/{tenantId}/principals/{principalId}` locks the principal row with `SELECT ... FOR UPDATE` and, in one transaction and in order: 1. Publishes a revocation for the marker of each of the principal's keys. -2. Removes the principal from every statement naming it, writing each policy with its current `ETag`. A statement left with no principal is deleted, and a policy left with no statement is deleted. +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. 3. Deletes the principal's keys: rows, then vault entries. 4. Sets `deleted_at` on the principal row and commits, releasing the lock. The row stays as a tombstone. 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 on the lock until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. -A failure before step 4 rolls back the transaction and leaves the principal with narrower access. The revocations already published stand, and Ingot refills from the unchanged policies on the key's next request. The console retries; the call answers 204 once the principal is removed, and 204 for an id that is removed or never existed. +A failure at any step rolls the transaction back. The principal keeps every key and every statement it had, so a failed removal changes nothing at Hilt. The revocations already published stand and cost Ingot one refill from the unchanged policies. The console retries (see [recovery](#policy-changes-and-marker-rotation)); 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 clears `deleted_at`. 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's delegations, its policy, and the bucket row. Hilt revokes a service key's delegations over the bucket and publishes nothing for principal-bound keys: a marker names no bucket, and per-request delegations are not stored. Ingot removes the bucket from its registry on the same request. The registry is a Postgres table shared by every Ingot instance serving the network, and Ingot's local authorization refuses any bucket the registry does not know, so cached chains for the deleted bucket cannot be used on any instance once the row is gone. +`/s3/bucket/delete` deletes the bucket's delegations, its policy, and the bucket row. Hilt revokes a service key's delegations over the bucket and publishes nothing for principal-bound keys: a marker names no bucket, and per-request delegations are not stored. Ingot removes the bucket from its local database on the same request, and its local authorization refuses any bucket not found there, so cached chains for a deleted bucket cannot be used. -Deleting a tenant cascades over principals, policies, and access keys. As today, it publishes no revocations and deactivates the tenant's `did:plc`. +### Tenant removal + +`DELETE /tenants/{tenantId}` deletes a disabled tenant. It MUST publish a revocation for every delegation the tenant's keys hold, a service key's grants and a principal-bound key's marker alike, 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. Ingot drops every store of the tenant's keys when the records arrive. Today the deletion publishes nothing, and a warm key keeps serving from Ingot's cache until the next UTC midnight. ## Hilt - UCAN API @@ -338,10 +340,25 @@ Ingot caches, per access key, what Hilt's authorize response carries: the delega Operations that map to no Forge command are authorized at Hilt on every request: `ListBuckets`, `CreateBucket`, and `DeleteBucket`. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. -**The firehose consumer needs no change.** It drops every per-key store containing a revoked CID, and clears the key's derived signing key and tenant with it. Ingot adds every delegation an authorize response carries to the key's store, chain member or not, so each store for a principal-bound key contains the key's marker and a revocation of the marker drops the store. Ingot MUST keep adding every delegation the container carries. A store holding only chain members would hold nothing a rotation could name. The handler is idempotent and a revocation matching nothing is a no-op. A service key's store is cleared by the same path it uses today. +**The firehose consumer needs no change.** When Swarf reports a revoked CID, Ingot drops every per-key store holding a delegation with that CID, together with the key's cached signing key and tenant. Ingot MUST keep storing every delegation an authorize response carries, chain member or not: the marker is in no chain, and a store without it holds nothing a rotation could name. A revocation that matches no store is a no-op. Error mapping gains two rows: `TemporarilyUnavailable` renders as `ServiceUnavailable` (503) and `InvalidBucketPolicy` as `InvalidArgument` (400). `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. +## Fil One console + +The console holds two kinds of key per tenant. + +Its service key is the tenant-wide credential it signs with today, created with no bucket list and holding `s3:CreateBucket` and `s3:DeleteBucket`. It signs every request that has no member actor and every request no policy can authorize: tenant setup, bucket creation with the policy header, bucket deletion, and background workers. For that traffic the console's own role check is the whole of the enforcement, as today. + +For each member it holds one principal-bound key, created at the tenant's access-key route on the member's first request in the region. It signs everything the member does through the console: listing buckets and objects, reading, writing and deleting objects, and presigned URLs. Hilt and Ingot authorize that traffic against the member's policies, so bucket access is enforced at the storage system and the console's role checks stay in front of it. + +Consequences: + +- A member's key cannot create or delete a bucket, whether the console or the member created the key. The console signs those two operations with its service key after its own role check, so the tenant-wide credential stays in service. +- Hilt does not distinguish the console's key for a member from a key the member created themselves. Both are bound to the same principal, are authorized against the same policies, and are deleted with the principal. +- A presigned URL is authorized against the member's policies when it is redeemed, under the parent RFC's signature time bounds and the key's own expiry. +- A policy change reaches the member's console traffic the way it reaches every key of theirs: through the marker rotation. + ## Action vocabulary The policy vocabulary is Hilt's S3 permission set without the three bucket-level actions, plus a wildcard: @@ -372,15 +389,20 @@ Retention and legal-hold writes pass through the API like any other action. The ## Migration -Every existing key becomes a service key, because a key with no principal is exactly the parent RFC's key. Per Forge network: +Every existing key becomes a service key, because a key with no principal is exactly the parent RFC's key. Existing keys are kept: they are embedded in customers' workflows, they keep working unchanged, and customers rotate them when they choose. + +**Once per Forge network.** -1. Deploy Ingot with the two new error mappings. Ingot already enforces the cached effective action set, and Swarf's service needs no deployment; Hilt takes the client library with the nonce option. +1. Deploy Ingot with the two new error mappings. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the nonce option. 2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its marker and nothing is backfilled. -3. The console creates a principal for each existing org member in every existing org with `PUT /tenants/{tenantId}/principals/{principalId}`, then writes a policy per bucket naming the org's Owners and Admins. -4. The console flips the registry entry to `iam`, starts signing each member's traffic with a key bound to their principal, and sends `x-bucket-policy` on every bucket it creates. The service key it already holds keeps signing traffic with no member actor. -5. The console deletes the keys the tenant no longer uses, through the existing delete route. -No step takes the network's keys out of service. Until the console moves a member to a principal-bound key, that member's access is whatever their old key carries and no policy applies to it. A policy edit reaches a member only after step 4 has run for them. +**Once per organization.** The console creates a principal for each member with `PUT /tenants/{tenantId}/principals/{principalId}`, then writes a policy per bucket naming the organization's Owners and Admins. This changes nothing for members yet: the console still signs their traffic with its service key, and no policy is evaluated for a service key. + +**Once per region.** When every organization in the region has its principals and policies, the console switches the region's access model from `scoped-keys` to `iam`. The access model is a per-region setting in the console; its policy routes, its policy fan-out, and its per-member keys are active only in a region whose model is `iam`. From then on the console sends `x-bucket-policy` on every bucket it creates. + +**Once per member.** On the member's first request after the switch, the console creates a key bound to their principal and signs their traffic with it from then on. Until then the member's console traffic is signed with the service key, which reaches every bucket of the tenant, and no policy applies to it. A policy edit reaches a member only once they hold a principal-bound key. + +No step takes a key out of service. ## Alternatives considered @@ -527,14 +549,14 @@ The `delegation` table keeps its schema; a principal-bound key's one row in it i 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. -5. On a member's next request, Fil One calls `POST /tenants/{tenantId}/access-keys` with that member's `principalId`, signs their traffic with the returned key, and deletes the key they held before. +5. On a member's next request, Fil One calls `POST /tenants/{tenantId}/access-keys` with that member's `principalId` and signs their traffic with the returned key. #### Create a bucket 1. A member creates `photos` in the console. Fil One builds the policy naming the member and the org's Owners and Admins, and sends `PUT /photos` to Ingot signed with its service key, with the document base64-encoded in the signed `x-bucket-policy` header. 2. Ingot invokes `/s3/bucket/create` with the request. 3. Hilt verifies the signature, checks that the header is signed, decodes and validates the document, and creates the bucket and its policy in one transaction. -4. Ingot adds the bucket to its registry and answers the member. Their next request on `photos` reaches Hilt and is authorized against the policy. +4. Ingot adds the bucket to its local database and answers the member. Their next request on `photos` reaches Hilt and is authorized against the policy. #### Grant a member a bucket From bca83de7e13e964d65d715a335eea425c9e74c24 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Tue, 22 Sep 2026 13:18:21 +0200 Subject: [PATCH 14/40] rfc: remove markers; grant delegations for every command --- rfcs/2026-09-forge-s3-tenant-iam.md | 119 +++++++++++++--------------- 1 file changed, 56 insertions(+), 63 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 8a0ba7a..9f8b90d 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -18,13 +18,13 @@ An access key gains an optional principal. A key created with one carries whatev An access key on Hilt is a row of permissions and bucket names fixed at creation. Nothing can change what a key carries afterwards, and there is no object between the tenant and its keys that a change could attach to. The console therefore cannot give a member a subset of buckets today, and cannot widen or narrow that subset later without reissuing every key the member holds. -On Forge the ADR settles three things: each member is a principal at the storage system, a bucket's own policy is the only thing that grants access, and a key belongs to a principal and carries whatever the policies give that principal, evaluated per request. +On Forge the ADR settles three things: each member is a principal at the storage system, a bucket's policy is what grants a principal access to that bucket, and a key belongs to a principal and carries whatever the policies give that principal. ## Goals 1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `allow` minus `deny`, with an explicit `deny` winning. 2. A policy edit changes what every key bound to a named principal may do, with no key reissued. -3. Every change to a principal's effective actions is published to Swarf as a revocation before Hilt acknowledges it, and Ingot's existing firehose consumer clears Ingot's caches within firehose latency. +3. Every narrowing of a principal's effective actions, and every widening on a bucket a key already reaches, is published to Swarf as a revocation before Hilt acknowledges it, and Ingot's existing firehose consumer clears Ingot's caches within firehose latency. 4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps service keys for traffic that has no member actor. 5. One policy model governs every principal. Existing keys keep working, and a tenant moves to principals with no key reissued and no window in which the network refuses requests. @@ -45,8 +45,7 @@ The parent RFC's roles apply. Swarf is added: - **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. A bucket has at most one policy, and the policy 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 carries the permissions and buckets it was created with. It signs traffic that has no member actor, such as tenant setup, bucket create and delete, and background workers. A tenant may hold several. -- **Principal-bound key.** An S3 access key that authenticates a request and names a principal. It holds no authority of its own: what it may do is whatever the bucket policies give its principal at the time of the request. -- **Marker.** A delegation Hilt issues to each principal-bound key. It names a command no service handles and grants nothing. Hilt returns it in every `/s3/request/authorize` response, so the store Ingot caches for the key always contains it, and revoking it through Swarf is how a policy change clears that store. +* **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 delegation from the tenant for every Forge command granted by that bucket’s policy. Hilt updates these delegations whenever 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. ## Hilt - Tenant API @@ -55,7 +54,7 @@ The parent RFC's roles apply. Swarf is added: | 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, evaluated on each request. | +| `{ name, principalId, expiresAt? }` | Principal-bound key | Whatever the bucket policies give the principal, held as delegations that follow the policies. | Hilt MUST reject with 422 a body carrying `principalId` together with `permissions` or `buckets`. A principal-bound key has no permissions of its own, and accepting the fields would suggest otherwise. The management API's rule that `permissions` is required applies to the service-key shape only. @@ -109,9 +108,9 @@ A principal is represented as: } ``` -Creating a principal stores the row and nothing else. A principal has no DID, no vault entry, and no delegation, and it appears in no UCAN. On each request Hilt evaluates the bucket's policy for the principal and signs the per-request delegations with the tenant key, so the proof chain runs from the bucket to the tenant to Ingot. +Creating a principal stores the row and nothing else. A principal has no DID, no vault entry, and no delegation, and it appears in no UCAN. The policies reach the storage system through the principal's keys: each key holds delegations from the tenant over the buckets the policies grant its principal, and on each request Hilt evaluates the bucket's policy for the principal and re-delegates from the key to Ingot. The proof chain runs from the bucket to the tenant to the key to Ingot, as it does for a service key. -Ingot caches what Hilt answers for a principal-bound key exactly as it does for a service key: the proof chains, the effective actions on the addressed bucket, the derived signing key, and the tenant, until the next UTC midnight (see [Ingot](#ingot---s3-api)). Hilt is consulted on a cache miss and after a revocation. A warm key's requests go from Ingot to Sprue and Piri with no Hilt round trip. The designs that model principals or policies as stored UCAN delegations instead are in [alternatives considered](#alternatives-considered). +Ingot caches what Hilt answers for a principal-bound key exactly as it does for a service key: the proof chains, the effective actions on the addressed bucket, the derived signing key, and the tenant, until the next UTC midnight (see [Ingot](#ingot---s3-api)). Hilt is consulted on a cache miss and after a revocation. A warm key's requests go from Ingot to Sprue and Piri with no Hilt round trip. The designs that give a principal key material, or that clear Ingot's cache without a stored grant, are in [alternatives considered](#alternatives-considered). The principal's effective actions are read through `GET /principals/{principalId}/access`: @@ -188,7 +187,7 @@ where `s3:*` stands for every action in the policy vocabulary. Hilt returns the Buckets are created and deleted over S3 with a service key, and only with a service key. A bucket policy grants actions on the bucket it is stored on; `CreateBucket` acts on a bucket that does not exist yet and `DeleteBucket` on the bucket whose policy would have to grant it, so no policy can carry either. A principal-bound key receives `AccessDenied` from both. `aws s3 mb` with a member's key therefore fails, and members create buckets through the console. This is a limit of the policy model, and the ADR accepts it: `CreateBucket` and `DeleteBucket` come off member keys in every region. The user-facing S3 documentation needs to state it. -A new bucket's policy travels on the create request, in the `x-bucket-policy` header: the policy document as JSON, base64-encoded. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header needs nothing from Ingot. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, and writes the policy. If the policy write fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps a request head at 8 KB, which leaves roughly 5 KB of JSON for the document. +A new bucket's policy travels on the create request, in the `x-bucket-policy` header: the policy document as JSON, base64-encoded. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header needs nothing from Ingot. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, writes the policy, and issues every key of each named principal its delegations over the new bucket, all in one transaction. If the policy write fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps a request head at 8 KB, which leaves roughly 5 KB of JSON for the document. The console sends the header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written through the management API. @@ -208,43 +207,45 @@ The console sends the header on every bucket it creates. The document names the Hilt MUST generate and store the key exactly as the parent RFC specifies, MUST record the principal on the key row, and MUST store `NULL` for its permissions and its bucket list. 422 on a `principalId` that is not a live principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. -Hilt MUST also issue the key one delegation, its marker, and store it in the `delegation` table with the tenant's other grants: +Hilt MUST also issue the key its delegations and store them in the `delegation` table with the tenant's other grants. 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:plc:tenant", - "cmd": "/s3/key/marker", + "sub": "did:key:bucket", + "cmd": "/blob/add", // exp: the key's expiresAt, or none } ``` -The marker grants nothing. No service handles its command and it is a hop in no proof chain, so Sprue and Piri never see it. It exists so that the key holds a delegation whose CID Ingot caches and Hilt can revoke. Every delegation carries a random nonce, so each marker has its own CID. A principal-bound key that has no marker is refused with `UnknownAccessKey`. +A member whose policies grant `s3:PutObject` on `photos` and `s3:GetObject` on `backups`, holding two keys, gets six delegations over `photos` and one over `backups` on each key. The stored set for a key and a bucket is always `CommandsFor(effective(principal, bucket))`. Every write below keeps that inside its transaction, so a request that passes the action check always resolves a chain, and a key whose principal reaches no bucket holds no delegations. ucantone gives every delegation a random nonce, so a reissued set never carries a CID that was revoked. The shape is the parent RFC's: a service key scoped to named buckets holds the same rows, derived from its permission list. A service key's name stays unique within the tenant, as today. A principal-bound key's name is unique 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 the `principal` field. A key without it is a service key and carries its own `permissions` and `buckets`. 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, then the delegations, the row, and the vault entry. For a principal-bound key the only delegation is the marker, so Ingot drops that key's store and no other. +`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 delegations, the row, and the vault entry. Ingot drops that key's store and no other. Both kinds count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. -### Policy changes and marker rotation +### Policy changes and delegation rotation + +A policy `PUT` or `DELETE` changes the effective actions of the principals it names on that bucket, with `"*"` standing for every live principal of the tenant. For each principal named in the old or the new document, Hilt compares the effective set before and after. For every key bound to a principal whose set changed, Hilt rewrites the key's delegations over the bucket before acknowledging the write: it publishes a revocation for each delegation the key holds over the bucket, then issues and stores the set the new policy gives. To publish a revocation is to send Swarf a `/ucan/revoke` invocation for the delegation, signed by the tenant that issued it, and all revocations of one write travel in one Swarf request (see [Swarf](#swarf)). Delegations over other buckets are untouched. -A policy `PUT` or `DELETE` changes the effective actions of the principals it names, with `"*"` standing for every live principal of the tenant. For each principal named in the old or the new document, Hilt compares the effective set before and after. For every key bound to a principal whose set changed, Hilt rotates the marker before acknowledging the write: it publishes a revocation of the current marker, then issues and stores a new one. To publish a revocation is to send Swarf a `/ucan/revoke` invocation for the delegation, signed by the tenant that issued it. A widening rotates as much as a narrowing, because Ingot refuses an action outside a cached set on its own and a grant would otherwise wait for the cache to expire at midnight. +A first grant of a bucket publishes nothing. The key held no delegation over it, and Ingot holds no cached set for a bucket it has not seen under that key, so the next request goes to Hilt and is authorized against the new policy. A widening of a bucket the key already reaches rotates as a narrowing does, because Ingot refuses an action outside a cached set on its own and the grant would otherwise wait for the cache to expire at midnight. -The marker is what makes the rotation possible. Ingot's store for a key holds the proof chains, the effective actions, and the marker, and Ingot drops the whole store when any delegation in it is revoked. Nothing else in the store names something Hilt could revoke: the chains do not identify the principal, and the per-request delegations are issued fresh and never stored at Hilt. Every authorize response carries the marker, so every store Ingot holds for the key contains it, and one revocation clears them all. +Ingot's store for a key holds the proof chains and the effective actions, and every chain contains one of the key's stored delegations. Ingot drops the whole store when any delegation in it is revoked, so revoking a key's delegations over one bucket clears its cache for every bucket, and the key's next request on any of them refills from Hilt. Revocation is exact at Hilt and Swarf. The refill is the cost at Ingot. -**Locking.** Hilt takes no row locks today; this RFC adds them on two paths. A policy write runs in one Postgres transaction that locks the policy row with `SELECT ... FOR UPDATE` and takes a transaction-scoped advisory lock on the bucket. Each marker rotation runs in a transaction that holds an advisory lock on that key's delegation set. Hilt publishes the revocations while those locks are held and commits afterwards. `/s3/request/authorize` reads the key row, the principal row, and the policy row with `SELECT ... FOR SHARE`, and reads the key's marker before the policy. An authorize request that arrives between the publish and the commit therefore waits for the commit and is answered from the new policy with the new marker. Without the locks, an Ingot that had consumed the revocation could refill its cache from the old policy, holding a marker already revoked, and keep it until midnight. +**Locking.** Hilt takes no row locks today; this RFC adds them on three paths. A policy write runs in one Postgres transaction that locks the policy row with `SELECT ... FOR UPDATE`, takes a transaction-scoped advisory lock on the bucket, and locks each affected principal's row with `SELECT ... FOR UPDATE` before it enumerates that principal's keys. Each key's rotation holds an advisory lock on that key's delegation set. Hilt publishes the revocations while those locks are held and commits afterwards. `/s3/request/authorize` reads the key row, the principal row, and the policy row with `SELECT ... FOR SHARE`, and reads the key's delegations before the policy. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes from the policies it can see. An authorize request that arrives between the publish and the commit therefore waits for the commit and is answered from the new policy with the new delegations, and a key created while a write is in flight is either seen by the rotation or created from the committed policy. Without the locks, an Ingot that had consumed the revocation could refill its cache from the old policy, holding delegations already revoked, and keep them until midnight. -Authorize requests for a bucket wait one Swarf round trip per rotated key during a write to that bucket's policy; the publishes MAY run concurrently. A write holds its transaction open while it waits on Swarf, for at most 8 seconds: the publishes of one write share that budget, and a write that has not finished them by then fails and rolls back. A reader or writer that cannot take its lock within 10 seconds fails as well, so a slow Swarf releases the locks before any reader times out. A management write that times out is 409 `ConcurrentChange`; an authorize that times out is `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). The locks do not cover an authorize answered before the write took its locks whose response reaches Ingot after the revocation does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. +Authorize requests for a bucket wait one Swarf round trip during a write to that bucket's policy, since the write's revocations travel in one request. A write holds its transaction open while it waits on Swarf, for at most 8 seconds, and a write that has not finished by then fails and rolls back. A reader or writer that cannot take its lock within 10 seconds fails as well, so a slow Swarf releases the locks before any reader times out. A management write that times out is 409 `ConcurrentChange`; an authorize that times out is `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). The locks do not cover an authorize answered before the write took its locks whose response reaches Ingot after the revocation does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. -**Failure.** If Swarf refuses or fails a publish, Hilt answers 500 and commits nothing. The old policy stays in force, and the principal never holds more than it did. The old marker is now revoked at Swarf while still in service at Hilt, so Ingot drops the key's store and, on the key's next request, refills it from the old policy with the old marker. The console retries the write. The retry revokes the old marker again, and Swarf records the repeat as a new event because every revocation Hilt publishes carries a fresh nonce (see [Swarf](#swarf)), so Ingot clears the store once more. A console that stops retrying leaves the old policy in force and nothing half-applied. The next write to that policy, from any caller, rotates the markers again. +**Failure.** If Swarf refuses or fails the publish, Hilt answers 500 and commits nothing. The old policy stays in force, and the principal never holds more than it did. If the publish succeeded and the commit then failed, the key's delegations are revoked at Swarf and still stored at Hilt: Ingot drops the key's store once and, on the key's next request, refills it from the old policy with those same delegations. The member's access is what the unchanged policy says. The console retries the write. The retry publishes the same revocations again, which Swarf records as the same invocations and ignores, so Ingot receives no second event and keeps the refilled store. The change reaches Ingot through a later write that revokes a different set, or when the cache expires at midnight. A console that stops retrying leaves the old policy in force and nothing half-applied. **Recovery.** Hilt runs no reconciliation, because a failed write leaves nothing to reconcile: every write that publishes runs in one transaction, and the state Hilt stores is always the state it enforces. What a failed call leaves behind 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. -Deleting a key revokes only that key's marker. The principal's other keys keep their cached proofs. +Deleting a key revokes only that key's delegations. The principal's other keys keep their cached proofs. The staleness bound for a change is the time from Hilt's acknowledgement to Ingot consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The revocation clears Ingot's caches and nothing else. Sprue and Piri do not consult Swarf, so a per-request delegation Ingot already holds stays valid at them until it expires. Only Ingot can use it: Ingot is the delegation's audience and signs every invocation that carries it, and once the revocation has cleared its cache it asks Hilt, which answers from the new policy. @@ -252,28 +253,28 @@ The staleness bound for a change is the time from Hilt's acknowledgement to Ingo `DELETE /tenants/{tenantId}/principals/{principalId}` locks the principal row with `SELECT ... FOR UPDATE` and, in one transaction and in order: -1. Publishes a revocation for the marker of each of the principal's keys. +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. 3. Deletes the principal's keys: rows, then vault entries. 4. Sets `deleted_at` on the principal row and commits, releasing the lock. The row stays as a tombstone. 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 on the lock until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. -A failure at any step rolls the transaction back. The principal keeps every key and every statement it had, so a failed removal changes nothing at Hilt. The revocations already published stand and cost Ingot one refill from the unchanged policies. The console retries (see [recovery](#policy-changes-and-marker-rotation)); the call answers 204 once the principal is removed, and 204 for an id that is removed or never existed. +A failure at any step rolls the transaction back. The principal keeps every key and every statement it had, so a failed removal changes nothing at Hilt. The revocations already published stand and cost Ingot one refill from the unchanged policies. The console retries (see [recovery](#policy-changes-and-delegation-rotation)); 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 clears `deleted_at`. 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's delegations, its policy, and the bucket row. Hilt revokes a service key's delegations over the bucket and publishes nothing for principal-bound keys: a marker names no bucket, and per-request delegations are not stored. Ingot removes the bucket from its local database on the same request, and its local authorization refuses any bucket not found there, so cached chains for a deleted bucket cannot be used. +`/s3/bucket/delete` deletes the bucket's delegations, its policy, and the bucket row. Hilt revokes every key's delegations over the bucket, service and principal-bound alike, in one Swarf request. Ingot removes the bucket from its local database on the same request, and its local authorization refuses any bucket not found there, so cached chains for a deleted bucket cannot be used. ### Tenant removal -`DELETE /tenants/{tenantId}` deletes a disabled tenant. It MUST publish a revocation for every delegation the tenant's keys hold, a service key's grants and a principal-bound key's marker alike, 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. Ingot drops every store of the tenant's keys when the records arrive. Today the deletion publishes nothing, and a warm key keeps serving from Ingot's cache until the next UTC midnight. +`DELETE /tenants/{tenantId}` deletes 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. Ingot drops every store of the tenant's keys when the records arrive. Today the deletion publishes nothing, and a warm key keeps serving from Ingot's cache until the next UTC midnight. ## Hilt - UCAN API -The commands, their argument types, and their result types keep their shapes. The authorization steps gain a branch on whether the key has a principal, the response container for a principal-bound key gains the key's marker, and bucket creation gains the policy header. +The commands, their argument types, and their result types keep their shapes. The authorization steps gain a branch on how the permitted actions are derived, and bucket creation gains the policy header. ### `/s3/request/authorize` @@ -282,17 +283,17 @@ For a request signed with a service key, the parent RFC's steps apply unchanged: 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 marker and its principal. A key with no marker, or whose principal is removed, is refused with `UnknownAccessKey`. +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. Issue one per-request delegation per Forge command mapped from the action, signed with the tenant key: +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:plc:tenant", + "iss": "did:key:accessKey", "aud": "did:web:ingot", // the invocation issuer "sub": "did:key:bucket", "cmd": "/content/retrieve", @@ -300,13 +301,13 @@ For a request signed with a principal-bound key, Hilt performs the parent RFC's } ``` -The expiry rule is the parent RFC's. The response container carries the per-request delegations, which are not stored, and the key's marker. The key, principal, and policy rows are read with a share lock (see [policy changes](#policy-changes-and-marker-rotation)). Signing with the tenant key reads the tenant's private key where the parent RFC's path reads the access key's, so the request path costs one vault read either way. +The expiry rule is the parent RFC's. The response container carries the per-request delegations, which are not stored, with their proof chains. The key, principal, and policy rows are read with a share lock (see [policy changes](#policy-changes-and-delegation-rotation)). The path reads the access key's private key from the vault, as the parent RFC's does. 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 is the bucket root alone, bucket to tenant with `cmd: "/"`, and the container carries the key's marker with it. Neither the principal nor the key is a hop. +For a principal-bound key the chain is the bucket root, bucket to tenant with `cmd: "/"`, followed by the key's stored delegations over that bucket, as for a service key scoped to named buckets. The key is a hop in the chain. The principal 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`. @@ -322,15 +323,15 @@ Unchanged. The listing is the tenant's whole bucket set, as AWS lists names the ### 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 with no marker or a removed principal. Ingot's existing mapping renders them as `NoSuchBucket` (404), `AccessDenied` (403), and `InvalidAccessKeyId` (403). +`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). Two failures are new. `TemporarilyUnavailable` is an authorize that could not take its share lock within the lock timeout; Ingot renders it as `ServiceUnavailable` (503) and the client retries. `InvalidBucketPolicy` is a create whose policy header is unsigned or fails validation; Ingot renders it as `InvalidArgument` (400). ## Swarf -Swarf's service needs no change. A marker rotation and a principal-bound key's deletion publish `/ucan/revoke` for the marker, a UCAN command Swarf serves on its RPC endpoint, self-signed by the tenant that issued the marker, with no witness path. That is the form of every revocation Hilt publishes. Swarf validates the marker's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. `GET /revocation/:cid` answers for a marker as for any delegation. Only Ingot ever holds one, so no other verifier asks. +Swarf's service needs no change. 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. Swarf validates the delegation's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. -Swarf's client library builds each revocation invocation without a nonce, so a repeat for the same delegation has the same CID and Swarf's insert ignores it. Marker rotation relies on a repeat recording a new event (see [policy changes](#policy-changes-and-marker-rotation)), so `Publish` gains a `WithNonce` option, and Hilt sets a fresh 16-byte nonce on every revocation it publishes. +All revocations of one write go to Swarf in one request. A ucantone server executes every invocation addressed to it in a request container, and the client library exposes that as `ExecuteBatch`, which returns one receipt per invocation. Swarf's client gains a batch publish that builds one `/ucan/revoke` invocation per delegation and sends them together, and Hilt reads each receipt so that a refused revocation fails the write. A `"*"` edit therefore costs one round trip however many keys it rotates, and the request grows with the fan-out (see [open questions](#open-questions)). ## Ingot - S3 API @@ -340,7 +341,7 @@ Ingot caches, per access key, what Hilt's authorize response carries: the delega Operations that map to no Forge command are authorized at Hilt on every request: `ListBuckets`, `CreateBucket`, and `DeleteBucket`. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. -**The firehose consumer needs no change.** When Swarf reports a revoked CID, Ingot drops every per-key store holding a delegation with that CID, together with the key's cached signing key and tenant. Ingot MUST keep storing every delegation an authorize response carries, chain member or not: the marker is in no chain, and a store without it holds nothing a rotation could name. A revocation that matches no store is a no-op. +**The firehose consumer needs no change.** When Swarf reports a revoked CID, Ingot drops every per-key store holding a delegation with that CID, together with the key's cached signing key and tenant. Every delegation in a store is a chain member, and every chain for a principal-bound key contains one of the key's stored delegations, so a rotation always names something the store holds. A revocation that matches no store is a no-op. Error mapping gains two rows: `TemporarilyUnavailable` renders as `ServiceUnavailable` (503) and `InvalidBucketPolicy` as `InvalidArgument` (400). `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. @@ -357,7 +358,7 @@ Consequences: - A member's key cannot create or delete a bucket, whether the console or the member created the key. The console signs those two operations with its service key after its own role check, so the tenant-wide credential stays in service. - Hilt does not distinguish the console's key for a member from a key the member created themselves. Both are bound to the same principal, are authorized against the same policies, and are deleted with the principal. - A presigned URL is authorized against the member's policies when it is redeemed, under the parent RFC's signature time bounds and the key's own expiry. -- A policy change reaches the member's console traffic the way it reaches every key of theirs: through the marker rotation. +- A policy change reaches the member's console traffic the way it reaches every key of theirs: through the rotation of the key's delegations. ## Action vocabulary @@ -393,8 +394,8 @@ Every existing key becomes a service key, because a key with no principal is exa **Once per Forge network.** -1. Deploy Ingot with the two new error mappings. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the nonce option. -2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its marker and nothing is backfilled. +1. Deploy Ingot with the two new error mappings. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the batch publish. +2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its delegations and nothing is backfilled. **Once per organization.** The console creates a principal for each member with `PUT /tenants/{tenantId}/principals/{principalId}`, then writes a policy per bucket naming the organization's Owners and Admins. This changes nothing for members yet: the console still signs their traffic with its service key, and no policy is evaluated for a service key. @@ -408,27 +409,19 @@ No step takes a key out of service. ### A principal invalidation record -Swarf could gain a `/principal/invalidate` command and a `principal` firehose event carrying a tenant and a `principalId`, and Ingot's consumer a branch that drops every store 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 stores by tenant and principal, and one key's deletion would clear every key the principal holds. The marker costs a few more records per change and changes neither Swarf nor Ingot's consumer. - -### One marker per principal +Swarf could gain a `/principal/invalidate` command and a `principal` firehose event carrying a tenant and a `principalId`, and Ingot's consumer a branch that drops every store 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 stores 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 single tenant-issued marker per principal, carried in every response for the principal's keys, would clear all of them with one revocation and cost one row per principal. It needs a new issue-and-rotate path in Hilt and a stored delegation bound to no key, and it clears every key of the principal when one is deleted. Per key, the same rows and revocations fall out of the paths a service key already uses, at the keys-per-principal factor in publishes. +### A marker delegation -### `allow` statements materialized as stored delegations - -Hilt could write one tenant-to-principal delegation per bucket and Forge command that an `allow` grants, and revoke exactly those on a narrowing. Stored authority would then equal granted authority, and revocation would name the precise delegation. It costs principals times buckets times commands rows, turns an Owner promotion into one delegation set per bucket, and still cannot express `deny`, which would be enforced at Hilt and Ingot regardless. One marker per key produces the same cache effect from one row per key. +Each principal-bound key could hold one tenant-issued delegation naming a command no service handles, carried in every authorize response so that Ingot's store always contains it, and rotated on a policy change. It costs one row per key and makes every change one revocation per key. The revoked delegation grants nothing, so Ingot dropping the store 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 each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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 Ingot drops the whole per-key store on any revoked CID anyway, so the precision changes nothing at the cache. Revoking them would also cut in-flight work at Sprue and Piri, on a widening as on a narrowing. - -### A did:key principal with a standing delegation - -Each principal could be an ed25519 key Hilt stores, holding one powerline delegation from the tenant that sits in every chain for the principal's keys. A narrowing would then rotate that delegation and revoke the old one by CID through Swarf's existing command, so Swarf and Ingot's consumer would change nothing and every downstream verifier would honor the revocation. A did:key principal could also 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 rotation on every narrowing. The marker keeps the rotation and drops the vault entry and the hop. It sits in no chain, so only Ingot acts on its revocation. +Hilt could persist each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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. -### The access key as a proof-chain hop +### A did:key principal in the chain -Storing a tenant-to-key delegation and re-delegating from the key per request would mirror today's mechanism, let a key deletion revoke one CID, and clear Ingot through the existing revocation path. It adds a hop to every chain and makes the key's delegation carry authority, which the ADR's model says it does not. +Each principal could be an ed25519 key Hilt stores, holding one delegation from the tenant that sits in every chain for the principal's keys, with the keys delegated from the principal. 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 @@ -461,7 +454,7 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac ## Open questions 1. What latency target does `GET /principals/{principalId}/access` carry? The console resolves it per request for its bucket list and activity feed. It is an index lookup at Hilt; the number is unmeasured. -2. A policy edit naming `"*"` rotates the marker of every key of every principal of the tenant, one Swarf round trip each inside the write's transaction. There is no figure for principals per tenant or keys per principal on Forge. The publishes run concurrently within the 8-second budget. If the product is large, that fan-out is the cost to watch. +2. A policy edit naming `"*"` rewrites the delegations of every key of every live principal of the tenant over that bucket: rows and tenant-key signatures inside the write, and one Swarf request whose size grows with the fan-out. A key's rows number its buckets times the commands its actions map to, up to seven 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 beyond the 8 KB request head on a create. ## Evaluation criteria @@ -499,9 +492,9 @@ CREATE TABLE principal ( ); -- A key with no principal is the parent RFC's key, authorized from its own --- permissions and buckets. A key with one carries no authority of its own and is --- authorized from the bucket policies, so both are NULL. Existing rows have no --- principal and need no backfill. +-- 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 TEXT, -- principalId @@ -539,7 +532,7 @@ CREATE UNIQUE INDEX bucket_policy_principal_wildcard_idx ON bucket_policy_princi CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (tenant_id, principal, bucket_id); ``` -The `delegation` table keeps its schema; a principal-bound key's one row in it is its marker. The vault keeps the parent RFC's layout: every key at `/tenant/{tenantDID}/access-key/{accessKeyDID}`, and no entry for a principal. +The `delegation` table keeps its schema; a principal-bound key's rows in it are its 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 @@ -562,27 +555,27 @@ The `delegation` table keeps its schema; a principal-bound key's one row in it i 1. Fil One reads `GET /tenants/{t}/buckets/photos/policy` and its `ETag`. 2. Fil One writes the document with an added `allow` statement, sending `If-Match`. -3. Hilt compares effective sets before and after for every named principal. The added member gained actions, so Hilt locks the policy row, publishes a revocation for each of the member's key markers under each key's lock, stores a new marker per key, and commits the document. The response carries the new `ETag`. -4. Swarf's firehose delivers the revocations and Ingot drops the store of every key that held one of the markers. The member's next request on `photos` reaches Hilt and is authorized against the new policy. +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 locks the policy row and the member's principal row, 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. #### 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 marker and principal, resolves `photos`, computes `effective(principal, photos)`, finds `s3:PutObject`, and signs tenant-to-Ingot delegations for the six write commands with the tenant key, expiring at the next UTC midnight. It returns them with the key's marker. -4. Ingot invokes `/s3/bucket/info` for the bucket root, caches the chains, the marker, and the effective set under the key, and serves the request. +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 writes the policy without the member's statement, with `If-Match`. -2. Hilt finds the member's effective set on the bucket shrank, locks the policy row, publishes a revocation for each of the member's key markers, stores new markers, 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 store of every key that held one of the markers. +2. Hilt finds the member's effective set on the bucket shrank to nothing, locks the policy row and the member's principal row, publishes a revocation for each delegation the member's keys hold over `photos` in one Swarf request, deletes those rows, 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 store of every key that held one of them. 4. The member's next request on any bucket reaches Hilt. On the removed bucket it receives `UnknownBucket`, which Ingot renders as `NoSuchBucket`. #### Remove a member 1. Fil One calls `DELETE /tenants/{t}/principals/{principalId}`. -2. Hilt locks the principal row, publishes a revocation for each of the member's key markers, strips the principal from every statement, deletes its keys, sets `deleted_at`, and commits. +2. Hilt locks the principal row, publishes a revocation for every delegation of the member's keys in one Swarf request, strips the principal from every statement, deletes its keys, sets `deleted_at`, and commits. 3. Ingot drops each key's store on its 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. From c7fa78e34c7f11518b21abf86d5b6ef19c5dda8d Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Tue, 22 Sep 2026 14:55:56 +0200 Subject: [PATCH 15/40] rfc: address comments and rewrite using more natural tone --- rfcs/2026-09-forge-s3-tenant-iam.md | 226 ++++++++++++++-------------- 1 file changed, 117 insertions(+), 109 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 9f8b90d..2bd2711 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -10,23 +10,23 @@ Extends: [Forge S3 tenant management](./2026-06-forge-s3-tenant-management.md) ## Introduction -Hilt gains principals, bucket policies, and access keys bound to principals, and Ingot enforces the actions those policies grant. Together they give the Fil One console what it needs to grant a member a subset 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. +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 subset 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 one carries whatever the bucket policies give that principal. A key created without one keeps the [parent RFC's](./2026-06-forge-s3-tenant-management.md) behavior, authorized from the permissions and buckets it was created with. The parent RFC's tenant, bucket, and delegation mechanics stay in force, and its access-key routes are unchanged. +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 -An access key on Hilt is a row of permissions and bucket names fixed at creation. Nothing can change what a key carries afterwards, and there is no object between the tenant and its keys that a change could attach to. The console therefore cannot give a member a subset of buckets today, and cannot widen or narrow that subset later without reissuing every key the member holds. +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 subset of buckets and later widen or narrow that access without reissuing every key the member holds. -On Forge the ADR settles three things: each member is a principal at the storage system, a bucket's policy is what grants a principal access to that bucket, and a key belongs to a principal and carries whatever the policies give that principal. +For Forge, the ADR 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. ## Goals -1. The storage system computes what a principal may do on a bucket from that bucket's policy alone, `allow` minus `deny`, with an explicit `deny` winning. -2. A policy edit changes what every key bound to a named principal may do, with no key reissued. -3. Every narrowing of a principal's effective actions, and every widening on a bucket a key already reaches, is published to Swarf as a revocation before Hilt acknowledges it, and Ingot's existing firehose consumer clears Ingot's caches within firehose latency. -4. The console signs a member's traffic, presigned URLs included, with a key bound to that member's principal, and keeps service keys for traffic that has no member actor. -5. One policy model governs every principal. Existing keys keep working, and a tenant moves to principals with no key reissued and no window in which the network refuses requests. +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. Ingot's existing firehose consumer then clears the affected caches within firehose latency. +4. The console signs member traffic, including presigned URLs, with a key bound to that member's principal, while retaining service keys for traffic with no member actor. +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. ## Concepts @@ -40,46 +40,46 @@ The parent RFC's roles apply. Swarf is added: ### Terms -- **Member.** A user of a Fil One organization. Fil One gives each member one of the roles Owner, Admin, Member, or ReadOnly. Hilt knows none of them. -- **Principal.** A member as Hilt knows them: `(tenant, principalId)` and nothing more. The console creates one principal per member. A principal carries no permissions, no role, and no key material. -- **Bucket policy.** A bucket's own document of statements. Each statement has an effect, a list of principals, and a list of S3 actions. A bucket has at most one policy, and the policy is deleted with the bucket. +- **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.** Hilt's representation of a member: `(tenant, principalId)` and nothing more. 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 carries the permissions and buckets it was created with. It signs traffic that has no member actor, such as tenant setup, bucket create and delete, and background workers. A tenant may hold several. -* **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 delegation from the tenant for every Forge command granted by that bucket’s policy. Hilt updates these delegations whenever 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. +- **Service key.** The parent RFC's access key. It has no principal and retains the permissions and buckets supplied at creation. It signs traffic with no member actor, such as tenant setup, bucket creation and deletion, and background work. 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. ## Hilt - Tenant API -`POST /tenants/{tenantId}/access-keys` creates both kinds of key. The body takes one of two shapes, and the shape decides the kind: +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 reject with 422 a body carrying `principalId` together with `permissions` or `buckets`. A principal-bound key has no permissions of its own, and accepting the fields would suggest otherwise. The management API's rule that `permissions` is required applies to the service-key shape only. +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. -`GET`, `DELETE`, and the tenant's key list serve both kinds. +The existing `GET`, `DELETE`, and tenant key-list operations apply to both key types. ### Tenant creation -`PUT /tenants/{tenantId}` is unchanged. Its body names the region, and Hilt binds the tenant to the provider that serves it. One Hilt serves several regions; a tenant serves one. The console creates the tenant and then creates a service key with no bucket list for provisioning buckets, as it does today. - -Fil One uses the organization's id as the tenant id. An organization served in two regions of the same Hilt would need two tenants, and this RFC gives them no id. Multi-region tenants are [FIL-1133](https://linear.app/filecoin-foundation/issue/FIL-1133). Nothing below depends on a tenant serving one region. +`PUT /tenants/{tenantId}` is unchanged. Its body currently names the region, and Hilt binds the tenant to the provider that serves it. This RFC does not change Hilt's tenant-to-region model; changes that allow a tenant to span multiple Forge regions are tracked separately in [FIL-1133](https://linear.app/filecoin-foundation/issue/FIL-1133). As today, the console creates the tenant and then creates a service key with no bucket list for bucket provisioning. Nothing in the IAM model below depends on a tenant serving exactly one region. ### Service keys -A service key is the parent RFC's access key, unchanged: an ed25519 `did:key` whose private key is stored at the vault path `/tenant/{tenantDID}/access-key/{accessKeyDID}`. It holds one delegation from the tenant per bucket and Forge command mapped from its permissions, or a powerline delegation with an undefined subject when it was created with no bucket list. +A service key is the parent RFC's access key, unchanged: an ed25519 `did:key` whose private key is stored at `/tenant/{tenantDID}/access-key/{accessKeyDID}` in the vault. Hilt stores one delegation for each bucket and Forge command derived from the key's permissions. When the key has no bucket list, Hilt instead stores the corresponding powerline delegations with an undefined subject. A service key: -- Reaches the buckets in its list, or every bucket of the tenant when it has none. It is named in no statement and no policy is evaluated for it. +- 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, as today. -- Keeps the parent RFC's optional `expiresAt`. Rotation is minting a new key, moving the caller to it, and deleting the old one. +- Keeps the parent RFC's optional `expiresAt`. Rotation consists of minting a new key, moving the caller to it, and deleting the old key. -Its bucket list is fixed at creation, so a service key scoped to named buckets cannot reach a bucket created afterwards. Tenant setup, bucket create, and bucket delete therefore use a key with no bucket list. +Because its bucket list is fixed at creation, a service key scoped to named buckets cannot reach buckets created later. Tenant setup, bucket creation, and bucket deletion therefore use a service key with no bucket list. -Deleting a service key publishes a revocation for each of its delegations, then deletes the delegations, the row, and the vault entry, as the parent RFC's key delete does. +Deleting a service key follows the parent RFC: Hilt publishes a revocation for each delegation, then deletes the delegations, key row, and vault entry. ### Principals @@ -95,9 +95,9 @@ Routes, all under `/tenants/{tenantId}/principals` and authenticated with the pa | GET | `/principals/{principalId}/access` | The principal's effective actions per bucket. | | GET | `/principals/{principalId}/access-keys` | List the principal's keys. | -A key bound to a principal is created at the tenant's access-key route with `principalId` set (see [principal-bound access keys](#principal-bound-access-keys)). +A principal-bound key is created through the tenant access-key route with `principalId` set (see [principal-bound access keys](#principal-bound-access-keys)). -`principalId` is an opaque string supplied by the caller, unique within the tenant. Hilt rejects with 422 an empty id, an id longer than 255 characters, and the id `*`. +`principalId` is an opaque caller-supplied string that is unique within the tenant. Hilt returns 422 for an empty ID, an ID longer than 255 characters, or the reserved ID `*`. A principal is represented as: @@ -108,9 +108,9 @@ A principal is represented as: } ``` -Creating a principal stores the row and nothing else. A principal has no DID, no vault entry, and no delegation, and it appears in no UCAN. The policies reach the storage system through the principal's keys: each key holds delegations from the tenant over the buckets the policies grant its principal, and on each request Hilt evaluates the bucket's policy for the principal and re-delegates from the key to Ingot. The proof chain runs from the bucket to the tenant to the key to Ingot, as it does for a service key. +Creating a principal stores only the principal row. 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 what Hilt answers for a principal-bound key exactly as it does for a service key: the proof chains, the effective actions on the addressed bucket, the derived signing key, and the tenant, until the next UTC midnight (see [Ingot](#ingot---s3-api)). Hilt is consulted on a cache miss and after a revocation. A warm key's requests go from Ingot to Sprue and Piri with no Hilt round trip. The designs that give a principal key material, or that clear Ingot's cache without a stored grant, are in [alternatives considered](#alternatives-considered). +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. Designs that give a principal key material or clear Ingot's cache without a stored grant are covered in [alternatives considered](#alternatives-considered). The principal's effective actions are read through `GET /principals/{principalId}/access`: @@ -123,9 +123,9 @@ The principal's effective actions are read through `GET /principals/{principalId } ``` -Buckets with an empty effective set are omitted. Hilt computes the answer from its own tables on every call and makes no call to another service, so the read is consistent with Hilt's last write. +Buckets with an empty effective set are omitted. Hilt computes the result from its own tables on every call and does not consult another service, so the read reflects Hilt's latest committed write. -A removed principal is kept as a tombstone (see [removal](#principal-removal)). It is absent from `GET` and the list, holds no keys, is named in no statement, and a `PUT` for its id revives it with nothing attached. +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 @@ -137,7 +137,7 @@ A bucket has at most one policy, addressed by the bucket's name: | PUT | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Create or replace. Carries `If-None-Match: *` or `If-Match`. | | DELETE | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Delete. Carries `If-Match`. | -The document follows the shape of an AWS bucket policy, reduced to what Forge evaluates: +The document follows the shape of an AWS bucket policy, limited to the fields Forge evaluates: ```jsonc { @@ -145,7 +145,7 @@ The document follows the shape of an AWS bucket policy, reduced to what Forge ev { "sid": "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 + "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 }, { @@ -166,7 +166,7 @@ Hilt MUST reject with 422: A bucket that does not exist or belongs to another tenant is 404 on all three routes. `GET` and `DELETE` on a bucket that has no policy are 404 as well, with a `PolicyNotFound` body that tells the two cases apart. -Hilt MUST accept a `deny` statement whose `principal` is `"*"`. The management API carries no actor; the console decides whether to write one. +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 management-API request. The console is responsible for deciding whether that member may write such a statement before calling Hilt. **Evaluation.** The effective actions of principal `p` on bucket `b` are @@ -175,21 +175,21 @@ effective(p, b) = union(action of allow statements naming p or "*") \ union(action of deny statements naming p or "*") ``` -where `s3:*` stands for every action in the policy vocabulary. Hilt returns the set sorted. A bucket with no policy has an empty effective set for every principal. A service key is not a principal and no policy is evaluated for it. +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` is outside the per-bucket set: every principal holds it. The S3 `ListBuckets` operation, which lists the tenant's bucket names and needs that action, is therefore authorized without reading any policy. It is a different operation from listing the objects in one bucket, which needs `s3:ListBucket` and is granted like any other action. +`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. **Compare-and-set.** `GET` returns an `ETag` computed over Hilt's canonical encoding of the stored document. `PUT` and `DELETE` MUST carry exactly one precondition: `If-Match` with that value, or, on a `PUT` that creates the first policy, `If-None-Match: *`. A request with neither, with both, or with another `If-None-Match` value is 400. A mismatch, or `If-None-Match: *` when a policy exists, is 412 and nothing is written. A successful `PUT` answers 201 when it created the policy and 200 when it replaced one, with the new `ETag` in the response header and no body. Callers treat the ETag as opaque. -**Storage.** Hilt stores the document with its bucket and maintains an index from principal to the buckets whose statements name it, so the two principal reads above cost an index lookup. Statements naming `"*"` are indexed under the tenant. The policy row is deleted with the bucket. +**Storage.** Hilt stores the document with its bucket and maintains an index from each principal to the buckets whose statements name that principal, making the two principal reads above index lookups. Statements naming `"*"` are indexed at the tenant level. The policy row is deleted with the bucket. ### Bucket creation -Buckets are created and deleted over S3 with a service key, and only with a service key. A bucket policy grants actions on the bucket it is stored on; `CreateBucket` acts on a bucket that does not exist yet and `DeleteBucket` on the bucket whose policy would have to grant it, so no policy can carry either. A principal-bound key receives `AccessDenied` from both. `aws s3 mb` with a member's key therefore fails, and members create buckets through the console. This is a limit of the policy model, and the ADR accepts it: `CreateBucket` and `DeleteBucket` come off member keys in every region. The user-facing S3 documentation needs to state it. +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. This RFC also deliberately excludes `DeleteBucket` from the policy vocabulary so that bucket lifecycle operations remain service-key-only, matching the ADR's decision that member keys cannot create or delete buckets. A principal-bound key therefore receives `AccessDenied` from both operations. `aws s3 mb` with a member's key fails, and members create buckets through the console. The user-facing S3 documentation needs to state this limitation. -A new bucket's policy travels on the create request, in the `x-bucket-policy` header: the policy document as JSON, base64-encoded. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header needs nothing from Ingot. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, writes the policy, and issues every key of each named principal its delegations over the new bucket, all in one transaction. If the policy write fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps a request head at 8 KB, which leaves roughly 5 KB of JSON for the document. +A new bucket's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header requires no new Ingot behavior. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, writes the policy, and issues every key of each named principal its delegations over the new bucket, all in one transaction. If the policy write fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps the request head at 8 KB, leaving roughly 5 KB for the decoded JSON policy under the expected request headers. The console MUST check the encoded request size before sending the create and refuse to submit a request that would exceed Ingot's header limit; an oversized request may otherwise be rejected by Ingot before it reaches Hilt. -The console sends the header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written through the management API. +The console includes this header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written through the management API. The policy can be added later through the Fil One console, which writes it through that management API. ### Principal-bound access keys @@ -203,9 +203,9 @@ The console sends the header on every bucket it creates. The document names the } ``` -`principalId` travels in the body rather than the path so that one route, `POST /tenants/{tenantId}/access-keys`, creates both kinds of key and the console's existing calls keep working. The shape of the body decides the kind. A second route, `POST /tenants/{tenantId}/principals/{principalId}/access-keys`, is discussed under [alternatives](#separate-creation-routes-per-kind). +`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. 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, MUST record the principal on the key row, and MUST store `NULL` for its permissions and its bucket list. 422 on a `principalId` that is not a live principal of the tenant, and 409 on a duplicate name for that principal. The response is the parent RFC's `CreatedAccessKey` with a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. +Hilt MUST generate and store the key exactly as the parent RFC specifies, record the principal on the key row, and store `NULL` for both permissions and bucket list. It 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 a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. Hilt MUST also issue the key its delegations and store them in the `delegation` table with the tenant's other grants. 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)): @@ -219,35 +219,41 @@ Hilt MUST also issue the key its delegations and store them in the `delegation` } ``` -A member whose policies grant `s3:PutObject` on `photos` and `s3:GetObject` on `backups`, holding two keys, gets six delegations over `photos` and one over `backups` on each key. The stored set for a key and a bucket is always `CommandsFor(effective(principal, bucket))`. Every write below keeps that inside its transaction, so a request that passes the action check always resolves a chain, and a key whose principal reaches no bucket holds no delegations. ucantone gives every delegation a random nonce, so a reissued set never carries a CID that was revoked. The shape is the parent RFC's: a service key scoped to named buckets holds the same rows, derived from its permission list. +For example, if a member's policies grant `s3:PutObject` on `photos` and `s3:GetObject` on `backups`, each of the member's two keys receives six delegations over `photos` and one over `backups`. The stored set for a key and a bucket is always `CommandsFor(effective(principal, bucket))`. Every write below keeps that inside its transaction, so a request that passes the action check always resolves a chain, and a key whose principal reaches no bucket holds no delegations. ucantone gives every delegation a random nonce, so a reissued set never carries a CID that was revoked. The shape is the parent RFC's: a service key scoped to named buckets holds the same rows, derived from its permission list. -A service key's name stays unique within the tenant, as today. A principal-bound key's name is unique within its principal, so two members may each hold a key named `laptop`. +A service key name remains unique within the tenant, as today. 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 the `principal` field. A key without it is a service key and carries its own `permissions` and `buckets`. 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 delegations, the row, and the vault entry. Ingot drops that key's store and no other. +`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 delegations, the row, and the vault entry. Ingot drops that key's per-key cache and no other. -Both kinds count toward `accessKeyCount`. Hilt enforces no key limit today, and none is added. +Both key types count toward `accessKeyCount`. Hilt enforces no key limit today, and this RFC adds none. ### Policy changes and delegation rotation -A policy `PUT` or `DELETE` changes the effective actions of the principals it names on that bucket, with `"*"` standing for every live principal of the tenant. For each principal named in the old or the new document, Hilt compares the effective set before and after. For every key bound to a principal whose set changed, Hilt rewrites the key's delegations over the bucket before acknowledging the write: it publishes a revocation for each delegation the key holds over the bucket, then issues and stores the set the new policy gives. To publish a revocation is to send Swarf a `/ucan/revoke` invocation for the delegation, signed by the tenant that issued it, and all revocations of one write travel in one Swarf request (see [Swarf](#swarf)). Delegations over other buckets are untouched. +A policy `PUT` or `DELETE` 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. -A first grant of a bucket publishes nothing. The key held no delegation over it, and Ingot holds no cached set for a bucket it has not seen under that key, so the next request goes to Hilt and is authorized against the new policy. A widening of a bucket the key already reaches rotates as a narrowing does, because Ingot refuses an action outside a cached set on its own and the grant would otherwise wait for the cache to expire 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. -Ingot's store for a key holds the proof chains and the effective actions, and every chain contains one of the key's stored delegations. Ingot drops the whole store when any delegation in it is revoked, so revoking a key's delegations over one bucket clears its cache for every bucket, and the key's next request on any of them refills from Hilt. Revocation is exact at Hilt and Swarf. The refill is the cost at Ingot. +**Locking.** Hilt takes no row locks today; this RFC introduces them on three paths. A policy write runs in a single Postgres transaction. It locks the policy row with `SELECT ... FOR UPDATE`, takes a transaction-scoped advisory lock on the bucket, and locks each affected principal row with `SELECT ... FOR UPDATE` before enumerating that principal's keys. Rotating a key also takes an advisory lock on that key's delegation set. Hilt publishes revocations while these locks are held and commits only afterwards. -**Locking.** Hilt takes no row locks today; this RFC adds them on three paths. A policy write runs in one Postgres transaction that locks the policy row with `SELECT ... FOR UPDATE`, takes a transaction-scoped advisory lock on the bucket, and locks each affected principal's row with `SELECT ... FOR UPDATE` before it enumerates that principal's keys. Each key's rotation holds an advisory lock on that key's delegation set. Hilt publishes the revocations while those locks are held and commits afterwards. `/s3/request/authorize` reads the key row, the principal row, and the policy row with `SELECT ... FOR SHARE`, and reads the key's delegations before the policy. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes from the policies it can see. An authorize request that arrives between the publish and the commit therefore waits for the commit and is answered from the new policy with the new delegations, and a key created while a write is in flight is either seen by the rotation or created from the committed policy. Without the locks, an Ingot that had consumed the revocation could refill its cache from the old policy, holding delegations already revoked, and keep them until midnight. +`/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, and reads the key's delegations before reading the policy. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, Ingot could consume a revocation and then refill its cache from the old policy, retaining already-revoked delegations until midnight. -Authorize requests for a bucket wait one Swarf round trip during a write to that bucket's policy, since the write's revocations travel in one request. A write holds its transaction open while it waits on Swarf, for at most 8 seconds, and a write that has not finished by then fails and rolls back. A reader or writer that cannot take its lock within 10 seconds fails as well, so a slow Swarf releases the locks before any reader times out. A management write that times out is 409 `ConcurrentChange`; an authorize that times out is `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). The locks do not cover an authorize answered before the write took its locks whose response reaches Ingot after the revocation does. Swarf polls its store once a second before it emits a record, so that response would have to be in flight for longer than that. +During a write to a bucket's policy, authorize requests for that bucket may wait for one Swarf round trip because all revocations from the write are sent in one request. The write keeps its transaction open while waiting for Swarf, up to 8 seconds; if it has not completed by then, it fails and rolls back. Readers and writers also fail if they cannot acquire their locks within 10 seconds, ensuring that a slow Swarf releases the locks before waiting readers time out. A timed-out management write returns 409 `ConcurrentChange`. A timed-out authorize returns `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). -**Failure.** If Swarf refuses or fails the publish, Hilt answers 500 and commits nothing. The old policy stays in force, and the principal never holds more than it did. If the publish succeeded and the commit then failed, the key's delegations are revoked at Swarf and still stored at Hilt: Ingot drops the key's store once and, on the key's next request, refills it from the old policy with those same delegations. The member's access is what the unchanged policy says. The console retries the write. The retry publishes the same revocations again, which Swarf records as the same invocations and ignores, so Ingot receives no second event and keeps the refilled store. The change reaches Ingot through a later write that revokes a different set, or when the cache expires at midnight. A console that stops retrying leaves the old policy in force and nothing half-applied. +The locks do not cover an authorize request that completed before the policy write acquired its locks but whose response reaches Ingot after the revocation. Swarf polls its store once per second before emitting a record, so such a response would need to remain in flight for longer than that interval. -**Recovery.** Hilt runs no reconciliation, because a failed write leaves nothing to reconcile: every write that publishes runs in one transaction, and the state Hilt stores is always the state it enforces. What a failed call leaves behind 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. +**Failure.** If Swarf rejects the publish or the publish fails, Hilt returns 500 and commits nothing. The old policy remains in force, so the principal never receives authority beyond the committed state. + +If Swarf accepts the revocations but the subsequent database commit fails, the delegations are revoked at Swarf but remain stored in Hilt. Ingot drops the key's per-key cache once; on the key's next request, it refills that cache from the unchanged policy and receives the same stored delegations. The member therefore retains exactly the access granted by the old policy. The console retries the write. Because the retry republishes the same revocation invocations, Swarf records them as duplicates and emits no second event, so Ingot keeps the refilled cache. The intended change reaches Ingot on a later write that revokes a different set, or when the cache expires at midnight. If the console stops retrying, the old policy remains in force and Hilt has no partially applied state. + +**Recovery.** Hilt performs no reconciliation because a failed write leaves no partially committed Hilt state to reconcile: every publishing write runs in one transaction, and Hilt always enforces the state it stores. What a failed call leaves behind 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. Deleting a key revokes only that key's delegations. The principal's other keys keep their cached proofs. -The staleness bound for a change is the time from Hilt's acknowledgement to Ingot consuming the firehose record. It is bounded by firehose latency and is independent of the midnight horizon. The revocation clears Ingot's caches and nothing else. Sprue and Piri do not consult Swarf, so a per-request delegation Ingot already holds stays valid at them until it expires. Only Ingot can use it: Ingot is the delegation's audience and signs every invocation that carries it, and once the revocation has cleared its cache it asks Hilt, which answers from the new policy. +The staleness bound for a policy change is the interval between Hilt acknowledging the change and Ingot consuming the corresponding firehose record. It is bounded by firehose 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. ### Principal removal @@ -258,27 +264,27 @@ The staleness bound for a change is the time from Hilt's acknowledgement to Ingo 3. Deletes the principal's keys: rows, then vault entries. 4. Sets `deleted_at` on the principal row and commits, releasing the lock. -The row stays as a tombstone. 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 on the lock until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. +The principal row remains as a tombstone. 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 on the lock until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. -A failure at any step rolls the transaction back. The principal keeps every key and every statement it had, so a failed removal changes nothing at Hilt. The revocations already published stand and cost Ingot one refill from the unchanged policies. The console retries (see [recovery](#policy-changes-and-delegation-rotation)); the call answers 204 once the principal is removed, and 204 for an id that is removed or never existed. +A failure at any step rolls back the transaction. The principal keeps every key and every statement it had, so a failed removal changes nothing at Hilt. The revocations already published stand and cost Ingot one refill from the unchanged policies. The console retries (see [recovery](#policy-changes-and-delegation-rotation)); 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 clears `deleted_at`. 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's delegations, its policy, and the bucket row. Hilt revokes every key's delegations over the bucket, service and principal-bound alike, in one Swarf request. Ingot removes the bucket from its local database on the same request, and its local authorization refuses any bucket not found there, so cached chains for a deleted bucket cannot be used. +`/s3/bucket/delete` deletes the bucket's delegations, policy, and bucket row. 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}` deletes 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. Ingot drops every store of the tenant's keys when the records arrive. Today the deletion publishes nothing, and a warm key keeps serving from Ingot's cache until the next UTC midnight. +`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. Ingot drops the per-key cache for every tenant key when the records arrive. Today the deletion publishes nothing, and a warm key keeps serving from Ingot's cache until the next UTC midnight. ## Hilt - UCAN API -The commands, their argument types, and their result types keep their shapes. The authorization steps gain a branch on how the permitted actions are derived, and bucket creation gains the policy header. +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 bucket creation gains the policy header. ### `/s3/request/authorize` -For a request signed with a service key, the parent RFC's steps apply unchanged: the action must be in the key's `permissions`, and the bucket must be in its `buckets` or the key must have none. +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: @@ -301,13 +307,13 @@ For a request signed with a principal-bound key, Hilt performs the parent RFC's } ``` -The expiry rule is the parent RFC's. The response container carries the per-request delegations, which are not stored, with their proof chains. The key, principal, and policy rows are read with a share lock (see [policy changes](#policy-changes-and-delegation-rotation)). The path reads the access key's private key from the vault, as the parent RFC's does. 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. +Expiry follows the parent RFC. The response container carries the per-request delegations, which are not stored, with their proof chains. The key, principal, and policy rows are read with a share lock (see [policy changes](#policy-changes-and-delegation-rotation)). The path reads the access key's private key from the vault, as the parent RFC's does. 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 is the bucket root, bucket to tenant with `cmd: "/"`, followed by the key's stored delegations over that bucket, as for a service key scoped to named buckets. The key is a hop in the chain. The principal appears in no UCAN. +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`. @@ -319,39 +325,41 @@ Create reads the `x-bucket-policy` header from the forwarded request when it is ### `/s3/bucket/list` -Unchanged. The listing is the tenant's whole bucket set, as AWS lists names the caller cannot open. The console filters its own list against the principal's effective actions. +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). -Two failures are new. `TemporarilyUnavailable` is an authorize that could not take its share lock within the lock timeout; Ingot renders it as `ServiceUnavailable` (503) and the client retries. `InvalidBucketPolicy` is a create whose policy header is unsigned or fails validation; Ingot renders it as `InvalidArgument` (400). +This RFC adds two failures. `TemporarilyUnavailable` is an authorize that could not take its share lock within the lock timeout; Ingot renders it as `ServiceUnavailable` (503) and the client retries. `InvalidBucketPolicy` is a create whose policy header is unsigned or fails validation; Ingot renders it as `InvalidArgument` (400). ## Swarf -Swarf's service needs no change. 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. Swarf validates the delegation's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. +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. Swarf validates the delegation's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. -All revocations of one write go to Swarf in one request. A ucantone server executes every invocation addressed to it in a request container, and the client library exposes that as `ExecuteBatch`, which returns one receipt per invocation. Swarf's client gains a batch publish that builds one `/ucan/revoke` invocation per delegation and sends them together, and Hilt reads each receipt so that a refused revocation fails the write. A `"*"` edit therefore costs one round trip however many keys it rotates, and the request grows with the fan-out (see [open questions](#open-questions)). +All revocations produced by one write are sent to Swarf in a single request. A ucantone server executes every invocation addressed to it in a request container, and the client library exposes that as `ExecuteBatch`, which returns one receipt per invocation. The Swarf client gains a batch-publish operation that builds one `/ucan/revoke` invocation per delegation and sends them together. Hilt checks every receipt, so any rejected revocation fails the write. A `"*"` policy edit therefore costs one Swarf round trip regardless of how many keys it rotates, although the request size grows with the fan-out (see [open questions](#open-questions)). ## Ingot - S3 API -Ingot's request path is unchanged: authorize locally from cache when it can, and ask Hilt otherwise. +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. -Ingot caches, per access key, what Hilt's authorize response carries: the delegations, the effective action set under each bucket Hilt named, the derived SigV4 verification key, and the tenant, all until the next UTC midnight plus clock skew. The fast path verifies the signature with the cached key, requires the action to be in the cached set for the bucket, and requires a chain to Ingot's agent for every Forge command the action maps to. Anything less goes to Hilt, whose answer refills the caches. The cached set is what makes `deny` enforceable. Several S3 actions map to the same commands: `s3:GetObject` and `s3:ListBucket` both need `/content/retrieve`, and `s3:PutObject` grants `/blob/remove` alongside `s3:DeleteObject`. A chain probe alone could not refuse one action of such a pair, or honor a policy that grants one without the other; the set does. A request for a bucket with no cached set goes to 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`, and `DeleteBucket`. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. -**The firehose consumer needs no change.** When Swarf reports a revoked CID, Ingot drops every per-key store holding a delegation with that CID, together with the key's cached signing key and tenant. Every delegation in a store is a chain member, and every chain for a principal-bound key contains one of the key's stored delegations, so a rotation always names something the store holds. A revocation that matches no store is a no-op. +**The firehose consumer needs no change.** When Swarf reports a revoked CID, Ingot drops every per-key cache whose proof chains contain a delegation with that CID, including the cached effective action sets, signing key, and tenant. 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 a no-op. Error mapping gains two rows: `TemporarilyUnavailable` renders as `ServiceUnavailable` (503) and `InvalidBucketPolicy` as `InvalidArgument` (400). `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. ## Fil One console -The console holds two kinds of key per tenant. +The console maintains two key types per tenant. -Its service key is the tenant-wide credential it signs with today, created with no bucket list and holding `s3:CreateBucket` and `s3:DeleteBucket`. It signs every request that has no member actor and every request no policy can authorize: tenant setup, bucket creation with the policy header, bucket deletion, and background workers. For that traffic the console's own role check is the whole of the enforcement, as today. +The service key is the tenant-wide credential the console uses today. It is created without a bucket list and holds `s3:CreateBucket` and `s3:DeleteBucket`. It signs every request that has no member actor and every request no policy can authorize: tenant setup, bucket creation with the policy header, bucket deletion, and background workers. For that traffic the console's own role check is the whole of the enforcement, as today. -For each member it holds one principal-bound key, created at the tenant's access-key route on the member's first request in the region. It signs everything the member does through the console: listing buckets and objects, reading, writing and deleting objects, and presigned URLs. Hilt and Ingot authorize that traffic against the member's policies, so bucket access is enforced at the storage system and the console's role checks stay in front of it. +For each member, the console stores one principal-bound key for the tenant. It creates the key through the tenant access-key route on that member's first request in the region, persists the returned credential, and reuses it for subsequent traffic from that member rather than minting a new key per request. The console uses that key for all member traffic: listing buckets and objects, reading, writing and deleting objects, and creating presigned URLs. Hilt and Ingot authorize this traffic against the member's bucket policies, so bucket access is enforced by the storage system while the console's role checks remain an additional front-end guard. Consequences: @@ -362,7 +370,7 @@ Consequences: ## Action vocabulary -The policy vocabulary is Hilt's S3 permission set without the three bucket-level actions, plus a wildcard: +The policy vocabulary is Hilt's S3 permission set excluding the three bucket-level actions, plus a wildcard: | S3 action | Forge commands | Note | | ------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- | @@ -390,42 +398,42 @@ Retention and legal-hold writes pass through the API like any other action. The ## Migration -Every existing key becomes a service key, because a key with no principal is exactly the parent RFC's key. Existing keys are kept: they are embedded in customers' workflows, they keep working unchanged, and customers rotate them when they choose. +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 two new error mappings. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the batch publish. -2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its delegations and nothing is backfilled. +2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal_id` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its delegations and nothing is backfilled. -**Once per organization.** The console creates a principal for each member with `PUT /tenants/{tenantId}/principals/{principalId}`, then writes a policy per bucket naming the organization's Owners and Admins. This changes nothing for members yet: the console still signs their traffic with its service key, and no policy is evaluated for a service key. +**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. This step does not yet change member behavior: the console continues to sign member traffic with its service key, and service keys are not evaluated against bucket policies. -**Once per region.** When every organization in the region has its principals and policies, the console switches the region's access model from `scoped-keys` to `iam`. The access model is a per-region setting in the console; its policy routes, its policy fan-out, and its per-member keys are active only in a region whose model is `iam`. From then on the console sends `x-bucket-policy` on every bucket it creates. +**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 routes, policy fan-out, and per-member keys are active only where the model is `iam`. From that point forward, the console includes `x-bucket-policy` on every bucket it creates. -**Once per member.** On the member's first request after the switch, the console creates a key bound to their principal and signs their traffic with it from then on. Until then the member's console traffic is signed with the service key, which reaches every bucket of the tenant, and no policy applies to it. A policy edit reaches a member only once they hold a principal-bound key. +**Once per member.** On the member's first request after the regional switch, the console creates a key bound to that member's principal, persists the returned credential, and reuses it for subsequent member traffic. Until that happens, the console continues to sign the member's traffic with the service key, which reaches every bucket in the tenant and is not subject to bucket policies. Policy changes therefore affect a member's console traffic only after the member has a principal-bound key. -No step takes a key out of service. +No migration step takes an existing key out of service. ## Alternatives considered ### A principal invalidation record -Swarf could gain a `/principal/invalidate` command and a `principal` firehose event carrying a tenant and a `principalId`, and Ingot's consumer a branch that drops every store 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 stores 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. +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 naming a command no service handles, carried in every authorize response so that Ingot's store always contains it, and rotated on a policy change. It costs one row per key and makes every change one revocation per key. The revoked delegation grants nothing, so Ingot dropping the store 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. +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 each per-request delegation it issues and revoke the outstanding ones for a principal and bucket on a narrowing. 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. +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 be an ed25519 key Hilt stores, holding one delegation from the tenant that sits in every chain for the principal's keys, with the keys delegated from the principal. 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. +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 disabled today by returning the admin role for 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. +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 @@ -433,23 +441,23 @@ Ingot embeds versitygw, whose policy engine is disabled today by returning the a ### An access model flag per tenant -A tenant could carry `scoped-keys` or `iam`, and Hilt would apply one 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 worker key and a member key at once under that rule, and the console holds both from the moment it creates its first principal. +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 worker key and a member key at once under that rule, and the console holds both from the moment it creates its first principal. ### A permission list on a principal-bound key -A principal-bound key could carry its own permission list, as an AWS session policy does, and be authorized against the intersection of that list and the policy result. A member could then hold a read-only key and a full key on the same buckets, and the console would not have to model the difference as two principals. It puts a second document in the path of every denial, so a 403 has two possible sources and Ingot's cached set is no longer the principal's effective set. Rejecting the fields with 422 keeps one answer to what a member may do. +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. ### A default policy template per tenant -Hilt could hold one policy document per tenant and copy it onto every bucket at create, so the create request would carry nothing. The console would have to keep the template in step with the organization's roster, which is a second copy of the same statements it writes on every bucket, and the template could not name the member who created the bucket. The header carries the same document once, on the request that needs it. +Hilt could store one policy template per tenant and copy it onto each bucket at creation, eliminating the policy from the create request. The console would then have to keep that template synchronized with the organization's roster, creating a second copy of the same statements already written to each bucket. The template also could not naturally include the member who initiated a specific bucket creation. Carrying the policy in the request header sends the document once, at the point where it is needed. ### The policy written after the create -The console could create the bucket over S3 and then write its policy through the management API, answering the member after both calls. A console failure between the two calls, or after the retry of the second, leaves a bucket that appears in the listing and that no member can open, with nothing to trigger another write. Carrying the policy on the create makes the two one. +The console could create the bucket over S3 and then write its policy through the management API, responding to the member only after both operations complete. A console failure between those operations, however, could leave a bucket visible in listings but inaccessible to every member, with no event to trigger another policy write. Carrying the policy on the create request makes bucket creation and initial policy installation one operation. ### Filtering the bucket listing -Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for access keys in Hilt PR #48 and closed as a divergence from AWS, where a listing shows names the caller cannot open. The console filters its own list, and Ingot's behavior is unchanged. +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 @@ -463,7 +471,7 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac - Authorization latency for a service key is unchanged from today's key. - 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 stored delegation per key of each principal whose effective actions changed. +- 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 and Ingot's firehose consumer need no changes. ## References @@ -480,7 +488,7 @@ Scoping `/s3/bucket/list` to the buckets a principal reaches was proposed for ac ### Schema -Changes to the parent RFC's schema. Types and constraints follow the existing migrations. +The following schema changes extend the parent RFC. Types and constraints follow the existing migrations. ```sql CREATE TABLE principal ( @@ -497,20 +505,20 @@ CREATE TABLE principal ( -- no principal and need no backfill. ALTER TABLE access_key ALTER COLUMN permissions DROP NOT NULL, - ADD COLUMN principal TEXT, -- principalId - ADD FOREIGN KEY (tenant_id, principal) REFERENCES principal(tenant_id, external_id) ON DELETE RESTRICT, + 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 IS NULL AND permissions IS NOT NULL) - OR (principal IS NOT NULL AND permissions IS NULL AND buckets IS NULL)); + 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 IS NULL; -CREATE UNIQUE INDEX access_key_principal_name_idx ON access_key (tenant_id, principal, name) - WHERE principal IS NOT NULL; + 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, @@ -519,20 +527,20 @@ CREATE TABLE bucket_policy ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Which principals a policy names; "*" statements are indexed as NULL principal. +-- 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 TEXT, - FOREIGN KEY (tenant_id, principal) REFERENCES principal(tenant_id, external_id) ON DELETE CASCADE + 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) WHERE principal IS NOT NULL; -CREATE UNIQUE INDEX bucket_policy_principal_wildcard_idx ON bucket_policy_principal (bucket_id) WHERE principal IS NULL; -CREATE INDEX bucket_policy_principal_idx ON bucket_policy_principal (tenant_id, principal, bucket_id); +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 keeps its schema; a principal-bound key's rows in it are its 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. +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 @@ -542,7 +550,7 @@ The `delegation` table keeps its schema; a principal-bound key's rows in it are 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. -5. On a member's next request, Fil One calls `POST /tenants/{tenantId}/access-keys` with that member's `principalId` and signs their traffic with the returned key. +5. On a member's next request, Fil One calls `POST /tenants/{tenantId}/access-keys` with that member's `principalId`, persists the returned credential, and signs that member's subsequent traffic with it. #### Create a bucket @@ -570,12 +578,12 @@ The `delegation` table keeps its schema; a principal-bound key's rows in it are 1. Fil One writes the policy without the member's statement, with `If-Match`. 2. Hilt finds the member's effective set on the bucket shrank to nothing, locks the policy row and the member's principal row, publishes a revocation for each delegation the member's keys hold over `photos` in one Swarf request, deletes those rows, 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 store of every key that held one of them. -4. The member's next request on any bucket reaches Hilt. On the removed bucket it receives `UnknownBucket`, which Ingot renders as `NoSuchBucket`. +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 locks the principal row, publishes a revocation for every delegation of the member's keys in one Swarf request, strips the principal from every statement, deletes its keys, sets `deleted_at`, and commits. -3. Ingot drops each key's store on its revocation. The keys no longer resolve at Hilt. +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. From 502f33b26d6d5a88bc2936e26235d62b293c70d2 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Tue, 22 Sep 2026 16:19:17 +0200 Subject: [PATCH 16/40] rfc: keep authorize's per-request delegations keyed to themselves The implementation mirrors the parent RFC's service-key path on /s3/request/authorize: each per-request delegation is keyed to itself and Ingot obtains the chain through the key's stored delegations over /s3/bucket/info, so the two key kinds answer in one shape. Bucket creation issues the named principals' delegations after the policy write rather than in one transaction with it; a failure still deletes the bucket with everything the create wrote. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 2bd2711..b1fc35c 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -187,7 +187,7 @@ where `s3:*` expands to every action in the policy vocabulary. Hilt returns the 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. This RFC also deliberately excludes `DeleteBucket` from the policy vocabulary so that bucket lifecycle operations remain service-key-only, matching the ADR's decision that member keys cannot create or delete buckets. A principal-bound key therefore receives `AccessDenied` from both operations. `aws s3 mb` with a member's key fails, and members create buckets through the console. The user-facing S3 documentation needs to state this limitation. -A new bucket's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header requires no new Ingot behavior. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, writes the policy, and issues every key of each named principal its delegations over the new bucket, all in one transaction. If the policy write fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps the request head at 8 KB, leaving roughly 5 KB for the decoded JSON policy under the expected request headers. The console MUST check the encoded request size before sending the create and refuse to submit a request that would exceed Ingot's header limit; an oversized request may otherwise be rejected by Ingot before it reaches Hilt. +A new bucket's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header requires no new Ingot behavior. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, writes the policy, and issues every key of each named principal its delegations over the new bucket. If the policy write or the issuance fails, Hilt deletes the bucket with whatever it had written. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps the request head at 8 KB, leaving roughly 5 KB for the decoded JSON policy under the expected request headers. The console MUST check the encoded request size before sending the create and refuse to submit a request that would exceed Ingot's header limit; an oversized request may otherwise be rejected by Ingot before it reaches Hilt. The console includes this header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written through the management API. The policy can be added later through the Fil One console, which writes it through that management API. @@ -307,7 +307,7 @@ For a request signed with a principal-bound key, Hilt performs the parent RFC's } ``` -Expiry follows the parent RFC. The response container carries the per-request delegations, which are not stored, with their proof chains. The key, principal, and policy rows are read with a share lock (see [policy changes](#policy-changes-and-delegation-rotation)). The path reads the access key's private key from the vault, as the parent RFC's does. 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. +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`. The key, principal, and policy rows are read with a share lock (see [policy changes](#policy-changes-and-delegation-rotation)). The path reads the access key's private key from the vault, as the parent RFC's does. 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. From d2cd4a6c94fae192874056a4f9ab1da3af349678 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Tue, 22 Sep 2026 18:03:18 +0200 Subject: [PATCH 17/40] rfc: Ingot refuses to cache delegations it has seen revoked A write whose revocations reach Swarf but whose commit fails left Ingot refilling its cache from the old delegations, and the console's retry republished identical revoke invocations that Swarf deduplicated, so the change waited for midnight. Ingot now records revoked CIDs until the next UTC midnight and serves, without caching, any authorize response that carries one. The failure and retry text moves into one Error recovery subsection. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 34 +++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index b1fc35c..32eea07 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -239,22 +239,34 @@ Ingot's per-key cache contains the proof chains, effective action sets, derived **Locking.** Hilt takes no row locks today; this RFC introduces them on three paths. A policy write runs in a single Postgres transaction. It locks the policy row with `SELECT ... FOR UPDATE`, takes a transaction-scoped advisory lock on the bucket, and locks each affected principal row with `SELECT ... FOR UPDATE` before enumerating that principal's keys. Rotating a key also takes an advisory lock on that key's delegation set. Hilt publishes revocations while these locks are held and commits only afterwards. -`/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, and reads the key's delegations before reading the policy. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, Ingot could consume a revocation and then refill its cache from the old policy, retaining already-revoked delegations until midnight. +`/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, and reads the key's delegations before reading the policy. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, an authorize request during a write would be answered from the old policy with delegations Ingot has seen revoked, and every request from the key would reach Hilt until the commit (see [error recovery](#error-recovery)). During a write to a bucket's policy, authorize requests for that bucket may wait for one Swarf round trip because all revocations from the write are sent in one request. The write keeps its transaction open while waiting for Swarf, up to 8 seconds; if it has not completed by then, it fails and rolls back. Readers and writers also fail if they cannot acquire their locks within 10 seconds, ensuring that a slow Swarf releases the locks before waiting readers time out. A timed-out management write returns 409 `ConcurrentChange`. A timed-out authorize returns `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). -The locks do not cover an authorize request that completed before the policy write acquired its locks but whose response reaches Ingot after the revocation. Swarf polls its store once per second before emitting a record, so such a response would need to remain in flight for longer than that interval. +The locks do not cover an authorize request that completed before the policy write acquired its locks but whose response reaches Ingot after the revocation. [Error recovery](#error-recovery) covers that response. -**Failure.** If Swarf rejects the publish or the publish fails, Hilt returns 500 and commits nothing. The old policy remains in force, so the principal never receives authority beyond the committed state. - -If Swarf accepts the revocations but the subsequent database commit fails, the delegations are revoked at Swarf but remain stored in Hilt. Ingot drops the key's per-key cache once; on the key's next request, it refills that cache from the unchanged policy and receives the same stored delegations. The member therefore retains exactly the access granted by the old policy. The console retries the write. Because the retry republishes the same revocation invocations, Swarf records them as duplicates and emits no second event, so Ingot keeps the refilled cache. The intended change reaches Ingot on a later write that revokes a different set, or when the cache expires at midnight. If the console stops retrying, the old policy remains in force and Hilt has no partially applied state. - -**Recovery.** Hilt performs no reconciliation because a failed write leaves no partially committed Hilt state to reconcile: every publishing write runs in one transaction, and Hilt always enforces the state it stores. What a failed call leaves behind 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. +**Failure.** A write that fails at any step, before or after publishing its revocations, is covered in [error recovery](#error-recovery). Deleting a key revokes only that key's delegations. The principal's other keys keep their cached proofs. The staleness bound for a policy change is the interval between Hilt acknowledging the change and Ingot consuming the corresponding firehose record. It is bounded by firehose 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 principal's removal, a bucket's deletion, a tenant's removal) runs in one transaction and publishes before it commits. 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 per-key cache, and records each revoked CID in an in-memory set kept until the next UTC midnight plus clock skew, the same horizon as the cache. On the key's next request Ingot misses the cache, calls Hilt, and receives the same stored delegations. Their CIDs are in the set, so Ingot authorizes the request from the response 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, and ucantone's random nonce gives them new CIDs. 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 drops the set and caches the stored delegations again. A retry that commits after that point reaches Ingot at the following midnight. + +**A response that outran a write.** An authorize response that left Hilt before a write took its locks can arrive at Ingot after the write's revocations. Its delegations are in the set, so Ingot serves that one request under the old state and caches nothing. Swarf polls its store once per second before emitting a record, so the response must stay in flight longer than that interval for this to occur. + +**Restart.** Ingot keeps the set in memory and a restart clears it together with the cache. A restart between a failed commit and its successful retry refills the cache from the stored delegations, and the retry's duplicate revocations emit nothing, so that key sees the change at midnight. + ### Principal removal `DELETE /tenants/{tenantId}/principals/{principalId}` locks the principal row with `SELECT ... FOR UPDATE` and, in one transaction and in order: @@ -266,7 +278,7 @@ The staleness bound for a policy change is the interval between Hilt acknowledgi The principal row remains as a tombstone. 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 on the lock until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. -A failure at any step rolls back the transaction. The principal keeps every key and every statement it had, so a failed removal changes nothing at Hilt. The revocations already published stand and cost Ingot one refill from the unchanged policies. The console retries (see [recovery](#policy-changes-and-delegation-rotation)); the call answers 204 once the principal is removed, and 204 for an id that is removed or never existed. +A failure at any step rolls back the transaction. The principal keeps every key and every statement it had, so a failed removal changes nothing at Hilt. The revocations already published stand, and the console retries (see [error recovery](#error-recovery)). 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 clears `deleted_at`. 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. @@ -349,7 +361,7 @@ The cached effective set is what makes `deny` enforceable. Several S3 actions ma Operations that map to no Forge command are authorized at Hilt on every request: `ListBuckets`, `CreateBucket`, and `DeleteBucket`. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. -**The firehose consumer needs no change.** When Swarf reports a revoked CID, Ingot drops every per-key cache whose proof chains contain a delegation with that CID, including the cached effective action sets, signing key, and tenant. 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 a no-op. +**The firehose consumer records what it revokes.** When Swarf reports a revoked CID, Ingot drops every per-key cache whose proof chains contain a delegation with that CID, including the cached effective action sets, signing key, and tenant, and adds the CID to an in-memory set kept until the next UTC midnight plus clock skew. 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 still enters the set. On a cache miss, Ingot checks the delegations in Hilt's response against the set. 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)). Error mapping gains two rows: `TemporarilyUnavailable` renders as `ServiceUnavailable` (503) and `InvalidBucketPolicy` as `InvalidArgument` (400). `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. @@ -402,7 +414,7 @@ Every existing key becomes a service key because a key without a principal is ex **Once per Forge network.** -1. Deploy Ingot with the two new error mappings. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the batch publish. +1. Deploy Ingot with the two new error mappings and the revoked set. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the batch publish. 2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal_id` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its delegations 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. This step does not yet change member behavior: the console continues to sign member traffic with its service key, and service keys are not evaluated against bucket policies. @@ -472,7 +484,7 @@ Hilt PR #48 proposed scoping `/s3/bucket/list` to the buckets reachable by a pri - 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 and Ingot's firehose consumer need no changes. +- Swarf's service needs no changes. Ingot's firehose consumer adds only the revoked set. ## References From b6d8eb325510e2cdbdc135bb17ef3abde5ed073e Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Thu, 24 Sep 2026 14:41:57 +0200 Subject: [PATCH 18/40] rfc: name the labels Fil One writes its default statements under Co-Authored-By: Claude Opus 5.5 (1M context) --- rfcs/2026-09-forge-s3-tenant-iam.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 32eea07..930c46e 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -143,7 +143,7 @@ The document follows the shape of an AWS bucket policy, limited to the fields Fo { "statement": [ { - "sid": "owners", // optional label; Hilt stores and returns it, nothing evaluates it + "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 @@ -157,6 +157,8 @@ The document follows the shape of an AWS bucket policy, limited to the fields Fo } ``` +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 reject with 422: - 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, From a0dbc2e6144a4b00750ea5f50b81a4c25245063d Mon Sep 17 00:00:00 2001 From: Srdjan Date: Fri, 25 Sep 2026 14:51:01 +0200 Subject: [PATCH 19/40] Update rfcs/2026-09-forge-s3-tenant-iam.md Co-authored-by: ash --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 930c46e..38cef49 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -16,7 +16,7 @@ An access key gains an optional principal. A key created with a principal derive ## 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 subset of buckets and later widen or narrow that access without reissuing every key the member holds. +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 ADR 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. From 5b56d2b65d675a6492e2f8c292efbc47e5a88412 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:13 +0200 Subject: [PATCH 20/40] rfc: drop the firehose-latency sentence from goal 3 Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 38cef49..9b488bf 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -24,7 +24,7 @@ For Forge, the ADR defines the principal-bound authorization path: each member i 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. Ingot's existing firehose consumer then clears the affected caches within firehose latency. +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 member traffic, including presigned URLs, with a key bound to that member's principal, while retaining service keys for traffic with no member actor. 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. From b75e768644e8e31e5ccd30e74147014d4ed480c7 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:20 +0200 Subject: [PATCH 21/40] rfc: define concepts before listing goals Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 9b488bf..19450d3 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -20,14 +20,6 @@ Today, a Hilt access key stores a fixed set of permissions and bucket names at c For Forge, the ADR 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. -## 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 member traffic, including presigned URLs, with a key bound to that member's principal, while retaining service keys for traffic with no member actor. -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. - ## Concepts ### Roles @@ -47,6 +39,14 @@ The parent RFC's roles apply. Swarf is added: - **Service key.** The parent RFC's access key. It has no principal and retains the permissions and buckets supplied at creation. It signs traffic with no member actor, such as tenant setup, bucket creation and deletion, and background work. 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 member traffic, including presigned URLs, with a key bound to that member's principal, while retaining service keys for traffic with no member actor. +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. From 41827196ae0bb41de9a3cba49914cd87df916f3a Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:31 +0200 Subject: [PATCH 22/40] rfc: say why the access-key body has no type field Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 19450d3..1ed1ad8 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -60,6 +60,8 @@ All management API additions and changes defined by this RFC MUST be reflected i 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 body carries no type field. 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. ### Tenant creation From 4011c5c74b0d8f8af8f58eaf7d29609af96576a5 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:31 +0200 Subject: [PATCH 23/40] rfc: drop the tenant creation section Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 1ed1ad8..2f6b53f 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -64,10 +64,6 @@ The body carries no type field. The console already sends `{ name, permissions, The existing `GET`, `DELETE`, and tenant key-list operations apply to both key types. -### Tenant creation - -`PUT /tenants/{tenantId}` is unchanged. Its body currently names the region, and Hilt binds the tenant to the provider that serves it. This RFC does not change Hilt's tenant-to-region model; changes that allow a tenant to span multiple Forge regions are tracked separately in [FIL-1133](https://linear.app/filecoin-foundation/issue/FIL-1133). As today, the console creates the tenant and then creates a service key with no bucket list for bucket provisioning. Nothing in the IAM model below depends on a tenant serving exactly one region. - ### Service keys A service key is the parent RFC's access key, unchanged: an ed25519 `did:key` whose private key is stored at `/tenant/{tenantDID}/access-key/{accessKeyDID}` in the vault. Hilt stores one delegation for each bucket and Forge command derived from the key's permissions. When the key has no bucket list, Hilt instead stores the corresponding powerline delegations with an undefined subject. @@ -497,7 +493,6 @@ Hilt PR #48 proposed scoping `/s3/bucket/list` to the buckets reachable by a pri - [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. -- [FIL-1133](https://linear.app/filecoin-foundation/issue/FIL-1133), multiple Forge regions per tenant. - Hilt PR #48, the closed proposal for a scoped bucket listing. ## Appendix From 249f12ba3a3b7a03e4cf3b211930d082dac8d72c Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:32 +0200 Subject: [PATCH 24/40] rfc: state the principal routes' common prefix correctly Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 2f6b53f..fc043fe 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -81,7 +81,7 @@ Deleting a service key follows the parent RFC: Hilt publishes a revocation for e ### Principals -Routes, all under `/tenants/{tenantId}/principals` and authenticated with the partner key: +Routes, all under `/tenants/{tenantId}` and authenticated with the partner key: | Method | Path | Purpose | | ------ | --------------------------------------- | -------------------------------------------------------------------------------- | From 5426ef9b9792a98c2a85bd5263692a5f959847f4 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:50 +0200 Subject: [PATCH 25/40] rfc: list a principal's keys through the tenant key-list route Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index fc043fe..0ed1007 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -91,9 +91,8 @@ Routes, all under `/tenants/{tenantId}` and authenticated with the partner key: | 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. | -| GET | `/principals/{principalId}/access-keys` | List the principal's keys. | -A principal-bound key is created through the tenant access-key route with `principalId` set (see [principal-bound access keys](#principal-bound-access-keys)). +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)). `principalId` is an opaque caller-supplied string that is unique within the tenant. Hilt returns 422 for an empty ID, an ID longer than 255 characters, or the reserved ID `*`. @@ -203,7 +202,7 @@ The console includes this header on every bucket it creates. The document names } ``` -`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. A second route, `POST /tenants/{tenantId}/principals/{principalId}/access-keys`, is discussed under [alternatives](#separate-creation-routes-per-kind). +`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, record the principal on the key row, and store `NULL` for both permissions and bucket list. It 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 a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. @@ -223,7 +222,7 @@ For example, if a member's policies grant `s3:PutObject` on `photos` and `s3:Get A service key name remains unique within the tenant, as today. 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 the `principal` field. A key without it is a service key and carries its own `permissions` and `buckets`. The key list does not carry each key's effective actions; the console reads them once per principal through the principal route. +`GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. A key without it is a service key and 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 delegations, the row, and the vault entry. Ingot drops that key's per-key cache and no other. From 9d489de4399e6fb180013a27f02421c6012de89c Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:50 +0200 Subject: [PATCH 26/40] rfc: say what the principal tombstone is for Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 0ed1007..6369ef6 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -122,7 +122,7 @@ The principal's effective actions are read through `GET /principals/{principalId Buckets with an empty effective set are omitted. Hilt computes the result from its own tables on every call and does not consult another service, so 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. +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. The row stays so that a removed id keeps its `createdAt` and a second `DELETE` or a later `PUT` finds it. ### Bucket policies From 32cdd4a7a43aa2d98e019d2859f5ff54c9ca9204 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:50 +0200 Subject: [PATCH 27/40] rfc: an empty policy is rejected; deletion is a separate request Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 6369ef6..2f0d282 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -160,7 +160,7 @@ Hilt MUST reject with 422: - 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. The caller deletes the policy instead, +- 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 404 on all three routes. `GET` and `DELETE` on a bucket that has no policy are 404 as well, with a `PolicyNotFound` body that tells the two cases apart. From 957c026f10c0474b7def73093eccd2d5e8617fac Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:47:50 +0200 Subject: [PATCH 28/40] rfc: the revoked set only withholds caching Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 2f0d282..d365a85 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -256,7 +256,7 @@ Every write that publishes revocations (a policy change, a key's deletion, a pri **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 per-key cache, and records each revoked CID in an in-memory set kept until the next UTC midnight plus clock skew, the same horizon as the cache. On the key's next request Ingot misses the cache, calls Hilt, and receives the same stored delegations. Their CIDs are in the set, so Ingot authorizes the request from the response 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. +**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 per-key cache, and records each revoked CID in an in-memory set kept until the next UTC midnight plus clock skew, the same horizon as the cache. On the key's next request Ingot misses the cache, calls Hilt, and receives the same stored delegations. Their CIDs are in the set, so Ingot authorizes the request from the response 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. Ingot handles every revocation the same way. The set only stops Ingot caching a response; Hilt's committed state decides the 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, and ucantone's random nonce gives them new CIDs. The key's next request caches them. The change reaches Ingot one request after the commit. From 1ccc532d07a17c90df0a1ce101f344d8e9839504 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:48:05 +0200 Subject: [PATCH 29/40] rfc: policy lists are sets and the ETag is a CID Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index d365a85..41de918 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -178,7 +178,9 @@ where `s3:*` expands to every action in the policy vocabulary. Hilt returns the `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. -**Compare-and-set.** `GET` returns an `ETag` computed over Hilt's canonical encoding of the stored document. `PUT` and `DELETE` MUST carry exactly one precondition: `If-Match` with that value, or, on a `PUT` that creates the first policy, `If-None-Match: *`. A request with neither, with both, or with another `If-None-Match` value is 400. A mismatch, or `If-None-Match: *` when a policy exists, is 412 and nothing is written. A successful `PUT` answers 201 when it created the policy and 200 when it replaced one, with the new `ETag` in the response header and no body. Callers treat the ETag as opaque. +**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. The ETag is the CID of the DAG-CBOR encoding of that document. Two documents that differ only in order or in duplicates have the same ETag. + +**Compare-and-set.** `GET` returns the `ETag`. `PUT` and `DELETE` MUST carry exactly one precondition: `If-Match` with that value, or, on a `PUT` that creates the first policy, `If-None-Match: *`. A request with neither, with both, or with another `If-None-Match` value is 400. A mismatch, or `If-None-Match: *` when a policy exists, is 412 and nothing is written. A successful `PUT` answers 201 when it created the policy and 200 when it replaced one, with the new `ETag` in the response header and no body. Callers treat the ETag as opaque. **Storage.** Hilt stores the document with its bucket and maintains an index from each principal to the buckets whose statements name that principal, making the two principal reads above index lookups. Statements naming `"*"` are indexed at the tenant level. The policy row is deleted with the bucket. From b4bb353698489217bf0e747250ea7171d0a6916f Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 25 Sep 2026 17:50:32 +0200 Subject: [PATCH 30/40] rfc: read and write bucket policies over S3 through /s3/bucket/policy Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 124 +++++++++++++++++++++------- 1 file changed, 92 insertions(+), 32 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 41de918..04e9616 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -126,15 +126,17 @@ A removed principal remains as a tombstone (see [removal](#principal-removal)). ### Bucket policies -A bucket has at most one policy, addressed by the bucket's name: +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: -| Method | Path | Purpose | -| ------ | ------------------------------------------------- | -------------------------------------------------------------- | -| GET | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Read the policy. Returns an `ETag`. | -| PUT | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Create or replace. Carries `If-None-Match: *` or `If-Match`. | -| DELETE | `/tenants/{tenantId}/buckets/{bucketName}/policy` | Delete. Carries `If-Match`. | +| 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`. | -The document follows the shape of an AWS bucket policy, limited to the fields Forge evaluates: +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, limited to the fields Forge evaluates. It is the body of `PutBucketPolicy` and of the `GetBucketPolicy` response: ```jsonc { @@ -156,16 +158,16 @@ The document follows the shape of an AWS bucket policy, limited to the fields Fo 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 reject with 422: +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 404 on all three routes. `GET` and `DELETE` on a bucket that has no policy are 404 as well, with a `PolicyNotFound` body that tells the two cases apart. +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 management-API request. The console is responsible for deciding whether that member may write such a statement before calling Hilt. +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 @@ -180,7 +182,7 @@ where `s3:*` expands to every action in the policy vocabulary. Hilt returns the **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. The ETag is the CID of the DAG-CBOR encoding of that document. Two documents that differ only in order or in duplicates have the same ETag. -**Compare-and-set.** `GET` returns the `ETag`. `PUT` and `DELETE` MUST carry exactly one precondition: `If-Match` with that value, or, on a `PUT` that creates the first policy, `If-None-Match: *`. A request with neither, with both, or with another `If-None-Match` value is 400. A mismatch, or `If-None-Match: *` when a policy exists, is 412 and nothing is written. A successful `PUT` answers 201 when it created the policy and 200 when it replaced one, with the new `ETag` in the response header and no body. Callers treat the ETag as opaque. +**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 stores the document with its bucket and maintains an index from each principal to the buckets whose statements name that principal, making the two principal reads above index lookups. Statements naming `"*"` are indexed at the tenant level. The policy row is deleted with the bucket. @@ -188,9 +190,9 @@ where `s3:*` expands to every action in the policy vocabulary. Hilt returns the 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. This RFC also deliberately excludes `DeleteBucket` from the policy vocabulary so that bucket lifecycle operations remain service-key-only, matching the ADR's decision that member keys cannot create or delete buckets. A principal-bound key therefore receives `AccessDenied` from both operations. `aws s3 mb` with a member's key fails, and members create buckets through the console. The user-facing S3 documentation needs to state this limitation. -A new bucket's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header requires no new Ingot behavior. Hilt decodes it, validates it exactly as `PUT .../policy` would, creates the bucket, writes the policy, and issues every key of each named principal its delegations over the new bucket. If the policy write or the issuance fails, Hilt deletes the bucket with whatever it had written. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `InvalidArgument` (400). Ingot caps the request head at 8 KB, leaving roughly 5 KB for the decoded JSON policy under the expected request headers. The console MUST check the encoded request size before sending the create and refuse to submit a request that would exceed Ingot's header limit; an oversized request may otherwise be rejected by Ingot before it reaches Hilt. +A new bucket's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header requires no new Ingot behavior. Hilt decodes it, validates it as `PutBucketPolicy` would, creates the bucket, writes the policy, and issues every key of each named principal its delegations over the new bucket. If the policy write or the issuance fails, Hilt deletes the bucket with whatever it had written. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `MalformedPolicy` (400). Ingot caps the request head at 8 KB, leaving roughly 5 KB for the decoded JSON policy under the expected request headers. The console MUST check the encoded request size before sending the create and refuse to submit a request that would exceed Ingot's header limit; an oversized request may otherwise be rejected by Ingot before it reaches Hilt. -The console includes this header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written through the management API. The policy can be added later through the Fil One console, which writes it through that management API. +The console includes this header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written with `PutBucketPolicy`, by that client or by the console. ### Principal-bound access keys @@ -232,7 +234,7 @@ Both key types count toward `accessKeyCount`. Hilt enforces no key limit today, ### Policy changes and delegation rotation -A policy `PUT` or `DELETE` 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 `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. @@ -242,7 +244,7 @@ Ingot's per-key cache contains the proof chains, effective action sets, derived `/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, and reads the key's delegations before reading the policy. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, an authorize request during a write would be answered from the old policy with delegations Ingot has seen revoked, and every request from the key would reach Hilt until the commit (see [error recovery](#error-recovery)). -During a write to a bucket's policy, authorize requests for that bucket may wait for one Swarf round trip because all revocations from the write are sent in one request. The write keeps its transaction open while waiting for Swarf, up to 8 seconds; if it has not completed by then, it fails and rolls back. Readers and writers also fail if they cannot acquire their locks within 10 seconds, ensuring that a slow Swarf releases the locks before waiting readers time out. A timed-out management write returns 409 `ConcurrentChange`. A timed-out authorize returns `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). +During a write to a bucket's policy, authorize requests for that bucket may wait for one Swarf round trip because all revocations from the write are sent in one request. The write keeps its transaction open while waiting for Swarf, up to 8 seconds; if it has not completed by then, it fails and rolls back. Readers and writers also fail if they cannot acquire their locks within 10 seconds, ensuring that a slow Swarf releases the locks before waiting readers time out. A timed-out policy write returns `ConcurrentChange`, which Ingot renders as `OperationAborted` (409). A timed-out authorize returns `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). The locks do not cover an authorize request that completed before the policy write acquired its locks but whose response reaches Ingot after the revocation. [Error recovery](#error-recovery) covers that response. @@ -293,7 +295,7 @@ A `PUT` for a removed principal's id clears `deleted_at`. The principal returns ## 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 bucket creation gains the policy header. +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, bucket creation gains the policy header, and one new command carries the policy operations. ### `/s3/request/authorize` @@ -334,7 +336,50 @@ For a principal-bound key, the chain consists of the bucket root, the bucket-to- A service key reaches them by holding `s3:CreateBucket` or `s3:DeleteBucket` in its permissions. A principal-bound key receives `OperationNotPermitted` from both. -Create reads the `x-bucket-policy` header from the forwarded request when it is present, checks that the header is signed, validates the decoded document as `PUT .../policy` does, creates the bucket, and stores the policy. A failed policy write deletes the bucket (see [bucket creation](#bucket-creation)). An unsigned or invalid document refuses the create with `InvalidBucketPolicy`. Delete removes the policy with the bucket, as described above. +Create reads the `x-bucket-policy` header from the forwarded request when it is present, checks that the header is signed, validates the decoded document as `/s3/bucket/policy` does, creates the bucket, and stores the policy. A failed policy write deletes the bucket (see [bucket creation](#bucket-creation)). An unsigned or invalid document refuses the create with `InvalidBucketPolicy`. Delete removes the policy with the bucket, as described above. + +### `/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 a unit result (`{}`). + +```jsonc +{ + "etag": "bafy...", + "policy": { "statement": [ /* ... */ ] } // GET only +} +``` ### `/s3/bucket/list` @@ -344,7 +389,18 @@ Unchanged. The operation returns the tenant's complete bucket set, matching AWS `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 two failures. `TemporarilyUnavailable` is an authorize that could not take its share lock within the lock timeout; Ingot renders it as `ServiceUnavailable` (503) and the client retries. `InvalidBucketPolicy` is a create whose policy header is unsigned or fails validation; Ingot renders it as `InvalidArgument` (400). +This RFC adds these failures: + +| Hilt failure | Meaning | Ingot renders as | +| ------------------------ | -------------------------------------------------------------------------- | ---------------------------- | +| `TemporarilyUnavailable` | An authorize could not take its share lock within the lock timeout. | `ServiceUnavailable` (503) | +| `InvalidBucketPolicy` | A policy document failed validation, in a create header or a `PutBucketPolicy`. | `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 take its locks within the lock timeout. | `OperationAborted` (409) | + +The client retries on 503 and 409. ## Swarf @@ -360,17 +416,17 @@ For each access key, Ingot caches the data carried by Hilt's authorize response: 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`, and `DeleteBucket`. No per-request delegation carries the key's expiry for them, so a cached set alone would outlive an expired key. +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`. Today Ingot answers `GetBucketPolicy` with `NoSuchBucketPolicy` and the other two with 501. **The firehose consumer records what it revokes.** When Swarf reports a revoked CID, Ingot drops every per-key cache whose proof chains contain a delegation with that CID, including the cached effective action sets, signing key, and tenant, and adds the CID to an in-memory set kept until the next UTC midnight plus clock skew. 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 still enters the set. On a cache miss, Ingot checks the delegations in Hilt's response against the set. 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)). -Error mapping gains two rows: `TemporarilyUnavailable` renders as `ServiceUnavailable` (503) and `InvalidBucketPolicy` as `InvalidArgument` (400). `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. +Error mapping gains the rows listed under [failures](#failures). `UnknownBucket` is already 404 and `OperationNotPermitted` already 403. ## Fil One console The console maintains two key types per tenant. -The service key is the tenant-wide credential the console uses today. It is created without a bucket list and holds `s3:CreateBucket` and `s3:DeleteBucket`. It signs every request that has no member actor and every request no policy can authorize: tenant setup, bucket creation with the policy header, bucket deletion, and background workers. For that traffic the console's own role check is the whole of the enforcement, as today. +The service key is the tenant-wide credential the console uses today. It is created without a bucket list and holds `s3:CreateBucket`, `s3:DeleteBucket`, and the three policy actions. It signs every request that has no member actor and every request no policy can authorize: tenant setup, bucket creation with the policy header, bucket deletion, policy reads and writes, and background workers. For that traffic the console's own role check is the whole of the enforcement, as today. For each member, the console stores one principal-bound key for the tenant. It creates the key through the tenant access-key route on that member's first request in the region, persists the returned credential, and reuses it for subsequent traffic from that member rather than minting a new key per request. The console uses that key for all member traffic: listing buckets and objects, reading, writing and deleting objects, and creating presigned URLs. Hilt and Ingot authorize this traffic against the member's bucket policies, so bucket access is enforced by the storage system while the console's role checks remain an additional front-end guard. @@ -383,7 +439,7 @@ Consequences: ## Action vocabulary -The policy vocabulary is Hilt's S3 permission set excluding the three bucket-level actions, plus a wildcard: +The policy vocabulary is Hilt's S3 permission set excluding the bucket-level actions, plus a wildcard: | S3 action | Forge commands | Note | | ------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------- | @@ -403,9 +459,9 @@ The policy vocabulary is Hilt's S3 permission set excluding the three bucket-lev | `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, and `s3:ListAllMyBuckets`, which every principal holds. `s3:*` never expands to any of the three. A service key still holds the two bucket actions through its own permissions, which is how the console creates and deletes buckets. +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`, as they do today, so `s3:ListBucket` covers them and Ingot's fast path applies. The management API's action enum, now used by policy statements, gains `s3:AbortMultipartUpload` and `s3:ListMultipartUploadParts`, which Hilt already accepts. +Bucket-configuration reads (`GET /{bucket}?versioning`, `GET /{bucket}?object-lock`) classify as `ListBucket`, as they do today, 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. @@ -415,12 +471,12 @@ Every existing key becomes a service key because a key without a principal is ex **Once per Forge network.** -1. Deploy Ingot with the two new error mappings and the revoked set. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the batch publish. +1. Deploy Ingot with the policy operations, the new error mappings, and the revoked set. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the batch publish. 2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal_id` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its delegations 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. This step does not yet change member behavior: the console continues to sign member traffic with its service key, and service keys are not evaluated against bucket policies. -**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 routes, policy fan-out, and per-member keys are active only where the model is `iam`. From that point forward, the console includes `x-bucket-policy` on every bucket it creates. +**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, policy fan-out, and per-member keys are active only where the model is `iam`. From that point forward, the console includes `x-bucket-policy` on every bucket it creates. **Once per member.** On the member's first request after the regional switch, the console creates a key bound to that member's principal, persists the returned credential, and reuses it for subsequent member traffic. Until that happens, the console continues to sign the member's traffic with the service key, which reaches every bucket in the tenant and is not subject to bucket policies. Policy changes therefore affect a member's console traffic only after the member has a principal-bound key. @@ -460,13 +516,17 @@ A tenant could carry an access-model flag, `scoped-keys` or `iam`, and Hilt coul 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, eliminating the policy from the create request. The console would then have to keep that template synchronized with the organization's roster, creating a second copy of the same statements already written to each bucket. The template also could not naturally include the member who initiated a specific bucket creation. Carrying the policy in the request header sends the document once, at the point where it is needed. ### The policy written after the create -The console could create the bucket over S3 and then write its policy through the management API, responding to the member only after both operations complete. A console failure between those operations, however, could leave a bucket visible in listings but inaccessible to every member, with no event to trigger another policy write. Carrying the policy on the create request makes bucket creation and initial policy installation one operation. +The console could create the bucket over S3 and then write its policy with `PutBucketPolicy`, responding to the member only after both operations complete. A console failure between those operations, however, could leave a bucket visible in listings but inaccessible to every member, with no event to trigger another policy write. Carrying the policy on the create request makes bucket creation and initial policy installation one operation. ### Filtering the bucket listing @@ -476,7 +536,7 @@ Hilt PR #48 proposed scoping `/s3/bucket/list` to the buckets reachable by a pri 1. What latency target does `GET /principals/{principalId}/access` carry? The console resolves it per request for its bucket list and activity feed. 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: rows and tenant-key signatures inside the write, and one Swarf request whose size grows with the fan-out. A key's rows number its buckets times the commands its actions map to, up to seven 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 beyond the 8 KB request head on a create. +3. Limits on statements per policy and principals per statement. None are specified here. The create header is bound by Ingot's 8 KB request head; `PutBucketPolicy` has no stated limit (AWS caps a bucket policy at 20 KB). ## Evaluation criteria @@ -561,7 +621,7 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe 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. +4. Fil One writes each bucket's policy with `PutBucketPolicy`, signed with its service key. 5. On a member's next request, Fil One calls `POST /tenants/{tenantId}/access-keys` with that member's `principalId`, persists the returned credential, and signs that member's subsequent traffic with it. #### Create a bucket @@ -573,8 +633,8 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe #### Grant a member a bucket -1. Fil One reads `GET /tenants/{t}/buckets/photos/policy` and its `ETag`. -2. Fil One writes the document with an added `allow` statement, sending `If-Match`. +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 locks the policy row and the member's principal row, 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. @@ -588,7 +648,7 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe #### Remove a member from a bucket -1. Fil One writes the policy without the member's statement, with `If-Match`. +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, locks the policy row and the member's principal row, publishes a revocation for each delegation the member's keys hold over `photos` in one Swarf request, deletes those rows, 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`. From 8ef0b040a30246cdd1141e6f1ddf268fa8508ce0 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Mon, 28 Sep 2026 15:54:43 +0200 Subject: [PATCH 31/40] rfc: one result shape for every /s3/bucket/policy method Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 04e9616..b3c773e 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -372,7 +372,7 @@ Hilt verifies the signature as `/s3/request/authorize` does and requires a servi #### Result -`GET` returns the stored document and its `ETag`. `PUT` returns the new `ETag`. `DELETE` returns a unit result (`{}`). +`GET` returns the stored document and its `ETag`. `PUT` returns the new `ETag`. `DELETE` returns the result with both empty. ```jsonc { From ebdb3e3d52c2799616fe7af19cc665c8d1f208a6 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Wed, 30 Sep 2026 14:58:31 +0200 Subject: [PATCH 32/40] rfc: improve writing --- rfcs/2026-09-forge-s3-tenant-iam.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index b3c773e..f6e8d70 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -10,7 +10,7 @@ Extends: [Forge S3 tenant management](./2026-06-forge-s3-tenant-management.md) ## 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 subset 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. +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. @@ -18,7 +18,7 @@ An access key gains an optional principal. A key created with a principal derive 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 ADR 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. +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 @@ -33,7 +33,7 @@ The parent RFC's roles apply. Swarf is added: ### 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.** Hilt's representation of a member: `(tenant, principalId)` and nothing more. The console creates one principal per member. A principal carries no permissions, role, or key material. +- **Principal.** Hilt's representation of a member defined as `(tenant, principalId)`. 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. It signs traffic with no member actor, such as tenant setup, bucket creation and deletion, and background work. A tenant may hold several service keys. @@ -252,7 +252,7 @@ The locks do not cover an authorize request that completed before the policy wri Deleting a key revokes only that key's delegations. The principal's other keys keep their cached proofs. -The staleness bound for a policy change is the interval between Hilt acknowledging the change and Ingot consuming the corresponding firehose record. It is bounded by firehose 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. +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 From eede97ac949ccb49f793e784082721ddd3df1864 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Wed, 30 Sep 2026 15:22:33 +0200 Subject: [PATCH 33/40] rfc: the console signs member traffic with its service key The console keeps one service key per tenant and enforces a scoped member's access itself from the principal's effective actions, instead of minting a principal-bound key per member. The per-member migration step is gone. Principal-bound keys stay for keys members mint themselves. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 35 +++++++++++++---------------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index f6e8d70..c7d58ad 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -36,7 +36,7 @@ The parent RFC's roles apply. Swarf is added: - **Principal.** Hilt's representation of a member defined as `(tenant, principalId)`. 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. It signs traffic with no member actor, such as tenant setup, bucket creation and deletion, and background work. A tenant may hold several service keys. +- **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 @@ -44,7 +44,7 @@ The parent RFC's roles apply. Swarf is added: 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 member traffic, including presigned URLs, with a key bound to that member's principal, while retaining service keys for traffic with no member actor. +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 @@ -75,7 +75,7 @@ A service key: - Appears in `GET /tenants/{tenantId}/access-keys`, counts toward `accessKeyCount`, and is deleted through the access-key delete route, as today. - 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. Tenant setup, bucket creation, and bucket deletion therefore use a service key with no bucket list. +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 delegations, key row, and vault entry. @@ -424,18 +424,16 @@ Error mapping gains the rows listed under [failures](#failures). `UnknownBucket` ## Fil One console -The console maintains two key types per tenant. +The console holds one service key per tenant, the tenant-wide credential it uses today. The key is created without a bucket list and holds `s3:CreateBucket`, `s3:DeleteBucket`, and the three policy actions. It signs every request the console sends: tenant setup, bucket creation with the policy header, bucket deletion, policy reads and writes, background workers, and every member's bucket and object traffic, presigned URLs included. -The service key is the tenant-wide credential the console uses today. It is created without a bucket list and holds `s3:CreateBucket`, `s3:DeleteBucket`, and the three policy actions. It signs every request that has no member actor and every request no policy can authorize: tenant setup, bucket creation with the policy header, bucket deletion, policy reads and writes, and background workers. For that traffic the console's own role check is the whole of the enforcement, as today. - -For each member, the console stores one principal-bound key for the tenant. It creates the key through the tenant access-key route on that member's first request in the region, persists the returned credential, and reuses it for subsequent traffic from that member rather than minting a new key per request. The console uses that key for all member traffic: listing buckets and objects, reading, writing and deleting objects, and creating presigned URLs. Hilt and Ingot authorize this traffic against the member's bucket policies, so bucket access is enforced by the storage system while the console's role checks remain an additional front-end guard. +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: -- A member's key cannot create or delete a bucket, whether the console or the member created the key. The console signs those two operations with its service key after its own role check, so the tenant-wide credential stays in service. -- Hilt does not distinguish the console's key for a member from a key the member created themselves. Both are bound to the same principal, are authorized against the same policies, and are deleted with the principal. -- A presigned URL is authorized against the member's policies when it is redeemed, under the parent RFC's signature time bounds and the key's own expiry. -- A policy change reaches the member's console traffic the way it reaches every key of theirs: through the rotation of the key's delegations. +- The console's own check is the whole of the enforcement for console traffic, as today. Hilt and Ingot enforce the policies directly for keys bound to a principal, which members mint for their own use. +- 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 @@ -474,11 +472,9 @@ Every existing key becomes a service key because a key without a principal is ex 1. Deploy Ingot with the policy operations, the new error mappings, and the revoked set. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the batch publish. 2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal_id` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its delegations 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. This step does not yet change member behavior: the console continues to sign member traffic with its service key, and service keys are not evaluated against bucket policies. - -**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, policy fan-out, and per-member keys are active only where the model is `iam`. From that point forward, the console includes `x-bucket-policy` on every bucket it creates. +**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 member.** On the member's first request after the regional switch, the console creates a key bound to that member's principal, persists the returned credential, and reuses it for subsequent member traffic. Until that happens, the console continues to sign the member's traffic with the service key, which reaches every bucket in the tenant and is not subject to bucket policies. Policy changes therefore affect a member's console traffic only after the member has a principal-bound key. +**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 includes `x-bucket-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. @@ -510,7 +506,7 @@ Ingot embeds versitygw, whose policy engine is currently disabled by assigning t ### 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 worker key and a member key at once under that rule, and the console holds both from the moment it creates its first principal. +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 @@ -534,7 +530,7 @@ Hilt PR #48 proposed scoping `/s3/bucket/list` to the buckets reachable by a pri ## Open questions -1. What latency target does `GET /principals/{principalId}/access` carry? The console resolves it per request for its bucket list and activity feed. It is an index lookup at Hilt; the number is unmeasured. +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: rows and tenant-key signatures inside the write, and one Swarf request whose size grows with the fan-out. A key's rows number its buckets times the commands its actions map to, up to seven 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. The create header is bound by Ingot's 8 KB request head; `PutBucketPolicy` has no stated limit (AWS caps a bucket policy at 20 KB). @@ -621,8 +617,7 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe 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. -5. On a member's next request, Fil One calls `POST /tenants/{tenantId}/access-keys` with that member's `principalId`, persists the returned credential, and signs that member's subsequent traffic with it. +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 @@ -636,7 +631,7 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe 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 locks the policy row and the member's principal row, 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. +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 From dfbfd3669e25175b5482ae109ce1ef1650b9cf9c Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Wed, 30 Sep 2026 17:25:56 +0200 Subject: [PATCH 34/40] rfc: the console also creates service keys for Owners and Admins Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 1 + 1 file changed, 1 insertion(+) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index c7d58ad..de057f3 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -431,6 +431,7 @@ Bucket policies are never evaluated for a service key, so on a region serving th Consequences: - The console's own check is the whole of the enforcement for console traffic, as today. 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. - 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. From 0559808249cde2a32a6009ae932e53dcfb2387a7 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Thu, 1 Oct 2026 10:55:54 +0200 Subject: [PATCH 35/40] rfc: state the lock order and strip a removed principal's policies before locking it A policy write locks the principals it names before its bucket and the policy row; reads hold one lock at a time; a principal removal revokes and strips its policies before it locks the principal row, so no path holds a principal while it waits for a bucket. A failed removal is retried from the step that failed. Co-Authored-By: Claude Opus 5.5 --- rfcs/2026-09-forge-s3-tenant-iam.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index de057f3..c9089bc 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -240,9 +240,9 @@ A principal's first grant on a bucket publishes no revocation because the key pr 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. -**Locking.** Hilt takes no row locks today; this RFC introduces them on three paths. A policy write runs in a single Postgres transaction. It locks the policy row with `SELECT ... FOR UPDATE`, takes a transaction-scoped advisory lock on the bucket, and locks each affected principal row with `SELECT ... FOR UPDATE` before enumerating that principal's keys. Rotating a key also takes an advisory lock on that key's delegation set. Hilt publishes revocations while these locks are held and commits only afterwards. +**Locking.** Hilt takes no row locks today; this RFC introduces them on four paths. A policy write runs in a single Postgres transaction and takes its locks in this order: the principal rows the new policy names, with `SELECT ... FOR KEY SHARE`; a transaction-scoped advisory lock on the bucket; the bucket row, with `FOR KEY SHARE`; and the policy row, with `FOR UPDATE`. It then locks each affected principal row with `SELECT ... FOR NO KEY UPDATE` before enumerating that principal's keys, and rotating a key takes an advisory lock on that key's delegation set. Hilt publishes revocations while these locks are held and commits only afterwards. The two principal lock modes do not conflict with each other, so a write never waits on its own locks, and both conflict with the `FOR UPDATE` a principal removal takes. A removal locks the principal row only after it has rewritten the policies naming the principal (see [principal removal](#principal-removal)), so no path holds a principal row while it waits for a bucket. -`/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, and reads the key's delegations before reading the policy. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, an authorize request during a write would be answered from the old policy with delegations Ingot has seen revoked, and every request from the key would reach Hilt until the commit (see [error recovery](#error-recovery)). +`/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, each in a short transaction of its own, and reads the key's delegations before reading the policy. A reader therefore holds at most one lock at a time and waits for at most one write. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, an authorize request during a write would be answered from the old policy with delegations Ingot has seen revoked, and every request from the key would reach Hilt until the commit (see [error recovery](#error-recovery)). During a write to a bucket's policy, authorize requests for that bucket may wait for one Swarf round trip because all revocations from the write are sent in one request. The write keeps its transaction open while waiting for Swarf, up to 8 seconds; if it has not completed by then, it fails and rolls back. Readers and writers also fail if they cannot acquire their locks within 10 seconds, ensuring that a slow Swarf releases the locks before waiting readers time out. A timed-out policy write returns `ConcurrentChange`, which Ingot renders as `OperationAborted` (409). A timed-out authorize returns `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). @@ -256,7 +256,7 @@ The staleness bound for a policy change is the interval between Hilt acknowledgi ### Error recovery -Every write that publishes revocations (a policy change, a key's deletion, a principal's removal, a bucket's deletion, a tenant's removal) runs in one transaction and publishes before it commits. 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. +Every write that publishes revocations (a policy change, a key's deletion, a bucket's deletion, a tenant's removal) runs in one transaction and publishes before it commits. A principal's removal runs in steps that are each idempotent (see [principal removal](#principal-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. @@ -272,16 +272,18 @@ If the console stops retrying, the old state stays in force and Hilt holds nothi ### Principal removal -`DELETE /tenants/{tenantId}/principals/{principalId}` locks the principal row with `SELECT ... FOR UPDATE` and, in one transaction and in order: +`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. -3. Deletes the principal's keys: rows, then vault entries. -4. Sets `deleted_at` on the principal row and commits, releasing the lock. +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 row with `SELECT ... FOR UPDATE`. 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 (rows, then vault entries), sets `deleted_at` on the principal row, and commits, releasing the lock. + +Steps 1 and 2 run before the lock because a policy write holds its bucket while it locks the principals it changes. A removal that held the principal row while it rewrote that bucket's policy would wait on the write while the write waited on it. The principal row remains as a tombstone. 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 on the lock until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. -A failure at any step rolls back the transaction. The principal keeps every key and every statement it had, so a failed removal changes nothing at Hilt. The revocations already published stand, and the console retries (see [error recovery](#error-recovery)). The call answers 204 once the principal is removed, and 204 for an id that is removed or never existed. +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 clears `deleted_at`. 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. @@ -652,6 +654,6 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe #### Remove a member 1. Fil One calls `DELETE /tenants/{t}/principals/{principalId}`. -2. Hilt locks the principal row, publishes a revocation for every delegation of the member's keys in one Swarf request, strips the principal from every statement, deletes its keys, sets `deleted_at`, and commits. +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 row, deletes its keys, sets `deleted_at`, 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. From 15070aa09316f1c5a0e4634a34e634cde13bf14e Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 2 Oct 2026 13:00:26 +0200 Subject: [PATCH 36/40] rfc: match the text to what Hilt does on the points Codex raised A create expands the wildcard as a policy write does; a key is deleted under its delegation lock; authorize reads no stored delegation and bucket info rereads the policy after the delegations; a tenant removal, like a principal removal, runs in idempotent steps, and a principal's key rows commit as they go; Hilt skips expired delegations when it publishes revocations; the console key holds every permission and the console deletes a creator-scoped key when a demotion narrows its creator below it; the two retention writes grant the catalog write without blob removal. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index c9089bc..42925cc 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -190,7 +190,7 @@ where `s3:*` expands to every action in the policy vocabulary. Hilt returns the 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. This RFC also deliberately excludes `DeleteBucket` from the policy vocabulary so that bucket lifecycle operations remain service-key-only, matching the ADR's decision that member keys cannot create or delete buckets. A principal-bound key therefore receives `AccessDenied` from both operations. `aws s3 mb` with a member's key fails, and members create buckets through the console. The user-facing S3 documentation needs to state this limitation. -A new bucket's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header requires no new Ingot behavior. Hilt decodes it, validates it as `PutBucketPolicy` would, creates the bucket, writes the policy, and issues every key of each named principal its delegations over the new bucket. If the policy write or the issuance fails, Hilt deletes the bucket with whatever it had written. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `MalformedPolicy` (400). Ingot caps the request head at 8 KB, leaving roughly 5 KB for the decoded JSON policy under the expected request headers. The console MUST check the encoded request size before sending the create and refuse to submit a request that would exceed Ingot's header limit; an oversized request may otherwise be rejected by Ingot before it reaches Hilt. +A new bucket's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header requires no new Ingot behavior. Hilt decodes it, validates it as `PutBucketPolicy` would, creates the bucket, writes the policy, and issues every key of each principal the policy reaches, with `"*"` expanded to every live principal as a policy write expands it, its delegations over the new bucket. If the policy write or the issuance fails, Hilt deletes the bucket with whatever it had written. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `MalformedPolicy` (400). Ingot caps the request head at 8 KB, leaving roughly 5 KB for the decoded JSON policy under the expected request headers. The console MUST check the encoded request size before sending the create and refuse to submit a request that would exceed Ingot's header limit; an oversized request may otherwise be rejected by Ingot before it reaches Hilt. The console includes this header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written with `PutBucketPolicy`, by that client or by the console. @@ -228,7 +228,7 @@ A service key name remains unique within the tenant, as today. A principal-bound `GET /tenants/{tenantId}/access-keys` and `GET /tenants/{tenantId}/access-keys/{accessKeyId}` return the `principal` field. A key without it is a service key and 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 delegations, the row, and the vault entry. Ingot drops that key's per-key cache and no other. +`DELETE /tenants/{tenantId}/access-keys/{accessKeyId}` deletes either kind in the same sequence, under the advisory lock on the key's delegation set so that a rotation of the key in flight waits for the outcome: a revocation for each of the key's delegations, published in one Swarf request, then the delegations, the row, and the vault entry. Ingot drops that key's per-key cache and no other. Both key types count toward `accessKeyCount`. Hilt enforces no key limit today, and this RFC adds none. @@ -242,7 +242,7 @@ Ingot's per-key cache contains the proof chains, effective action sets, derived **Locking.** Hilt takes no row locks today; this RFC introduces them on four paths. A policy write runs in a single Postgres transaction and takes its locks in this order: the principal rows the new policy names, with `SELECT ... FOR KEY SHARE`; a transaction-scoped advisory lock on the bucket; the bucket row, with `FOR KEY SHARE`; and the policy row, with `FOR UPDATE`. It then locks each affected principal row with `SELECT ... FOR NO KEY UPDATE` before enumerating that principal's keys, and rotating a key takes an advisory lock on that key's delegation set. Hilt publishes revocations while these locks are held and commits only afterwards. The two principal lock modes do not conflict with each other, so a write never waits on its own locks, and both conflict with the `FOR UPDATE` a principal removal takes. A removal locks the principal row only after it has rewritten the policies naming the principal (see [principal removal](#principal-removal)), so no path holds a principal row while it waits for a bucket. -`/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, each in a short transaction of its own, and reads the key's delegations before reading the policy. A reader therefore holds at most one lock at a time and waits for at most one write. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, an authorize request during a write would be answered from the old policy with delegations Ingot has seen revoked, and every request from the key would reach Hilt until the commit (see [error recovery](#error-recovery)). +`/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, each in a short transaction of its own, and reads no stored delegation. `/s3/bucket/info` reads the key's delegations and then the policy again, and refuses the request with `TemporarilyUnavailable` when the policy changed in between, so a chain is never paired with an action set from another revision. A reader therefore holds at most one lock at a time and waits for at most one write. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, an authorize request during a write would be answered from the old policy with delegations Ingot has seen revoked, and every request from the key would reach Hilt until the commit (see [error recovery](#error-recovery)). During a write to a bucket's policy, authorize requests for that bucket may wait for one Swarf round trip because all revocations from the write are sent in one request. The write keeps its transaction open while waiting for Swarf, up to 8 seconds; if it has not completed by then, it fails and rolls back. Readers and writers also fail if they cannot acquire their locks within 10 seconds, ensuring that a slow Swarf releases the locks before waiting readers time out. A timed-out policy write returns `ConcurrentChange`, which Ingot renders as `OperationAborted` (409). A timed-out authorize returns `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). @@ -256,7 +256,7 @@ The staleness bound for a policy change is the interval between Hilt acknowledgi ### Error recovery -Every write that publishes revocations (a policy change, a key's deletion, a bucket's deletion, a tenant's removal) runs in one transaction and publishes before it commits. A principal's removal runs in steps that are each idempotent (see [principal removal](#principal-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. +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. @@ -277,7 +277,7 @@ If the console stops retrying, the old state stays in force and Hilt holds nothi 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 row with `SELECT ... FOR UPDATE`. 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 (rows, then vault entries), sets `deleted_at` on the principal row, and commits, releasing the lock. +4. Publishes a revocation for any delegation the principal's keys gained since step 1, deletes the keys (each row under its delegation lock, committed as it goes, then the vault entries), sets `deleted_at` on the principal row, and commits, releasing the lock. Steps 1 and 2 run before the lock because a policy write holds its bucket while it locks the principals it changes. A removal that held the principal row while it rewrote that bucket's policy would wait on the write while the write waited on it. @@ -293,7 +293,7 @@ A `PUT` for a removed principal's id clears `deleted_at`. The principal returns ### 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. Ingot drops the per-key cache for every tenant key when the records arrive. Today the deletion publishes nothing, and a warm key keeps serving from Ingot's cache until the next UTC midnight. +`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. Today the deletion publishes nothing, and a warm key keeps serving from Ingot's cache until the next UTC midnight. ## Hilt - UCAN API @@ -406,7 +406,7 @@ 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. Swarf validates the delegation's signature and expiry, records the revocation, and emits a `revocation` event on `GET /revocations/:from`. +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 a single request. A ucantone server executes every invocation addressed to it in a request container, and the client library exposes that as `ExecuteBatch`, which returns one receipt per invocation. The Swarf client gains a batch-publish operation that builds one `/ucan/revoke` invocation per delegation and sends them together. Hilt checks every receipt, so any rejected revocation fails the write. A `"*"` policy edit therefore costs one Swarf round trip regardless of how many keys it rotates, although the request size grows with the fan-out (see [open questions](#open-questions)). @@ -426,14 +426,14 @@ Error mapping gains the rows listed under [failures](#failures). `UnknownBucket` ## Fil One console -The console holds one service key per tenant, the tenant-wide credential it uses today. The key is created without a bucket list and holds `s3:CreateBucket`, `s3:DeleteBucket`, and the three policy actions. It signs every request the console sends: tenant setup, bucket creation with the policy header, bucket deletion, policy reads and writes, background workers, and every member's bucket and object traffic, presigned URLs included. +The console holds one service key per tenant, the tenant-wide credential it uses today. 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 with the policy header, bucket 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, as today. 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. +- 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. @@ -449,8 +449,8 @@ The policy vocabulary is Hilt's S3 permission set excluding the bucket-level act | `s3:GetObjectRetention` | `/content/retrieve` | | | `s3:GetObjectLegalHold` | `/content/retrieve` | | | `s3:PutObject` | `/blob/add`, `/index/add`, `/upload/add`, `/content/retrieve`, `/blob/abort`, `/blob/remove` | | -| `s3:PutObjectRetention` | as `s3:PutObject` | | -| `s3:PutObjectLegalHold` | as `s3:PutObject` | | +| `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` | | From e0f0e4ec848846fdec8969b5b9b9a19a2f12955a Mon Sep 17 00:00:00 2001 From: Srdjan Date: Fri, 2 Oct 2026 16:13:13 +0200 Subject: [PATCH 37/40] Update rfcs/2026-09-forge-s3-tenant-iam.md Co-authored-by: ash --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 42925cc..102e720 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -107,7 +107,7 @@ A principal is represented as: Creating a principal stores only the principal row. 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. Designs that give a principal key material or clear Ingot's cache without a stored grant are covered in [alternatives considered](#alternatives-considered). +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`: From e51e346a9fcc2057e3de37756487e2de722868d6 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Fri, 2 Oct 2026 18:17:10 +0200 Subject: [PATCH 38/40] rfc: state the concurrency guarantees and trim implementation detail Rewrites the locking text as the guarantees a policy write gives its readers, drops lock modes, timeouts, table and column names from the body, and removes text that restates the parent RFC or describes the system before this change. Adds a type field to access-key responses and simplifies the principal term to an opaque id unique within the tenant. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 111 ++++++++++++++-------------- 1 file changed, 54 insertions(+), 57 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 102e720..a9717b1 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -33,7 +33,7 @@ The parent RFC's roles apply. Swarf is added: ### 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.** Hilt's representation of a member defined as `(tenant, principalId)`. The console creates one principal per member. A principal carries no permissions, role, or key material. +- **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. @@ -60,24 +60,24 @@ All management API additions and changes defined by this RFC MUST be reflected i 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 body carries no type field. The console already sends `{ name, permissions, buckets? }` to this route, and a required field would break that call. +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: an ed25519 `did:key` whose private key is stored at `/tenant/{tenantDID}/access-key/{accessKeyDID}` in the vault. Hilt stores one delegation for each bucket and Forge command derived from the key's permissions. When the key has no bucket list, Hilt instead stores the corresponding powerline delegations with an undefined subject. +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, as today. +- 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 delegations, key row, and vault entry. +Deleting a service key follows the parent RFC: Hilt publishes a revocation for each delegation, then deletes the key. ### Principals @@ -94,7 +94,7 @@ Routes, all under `/tenants/{tenantId}` and authenticated with the partner key: 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)). -`principalId` is an opaque caller-supplied string that is unique within the tenant. Hilt returns 422 for an empty ID, an ID longer than 255 characters, or the reserved ID `*`. +Hilt returns 422 for an empty `principalId`, one longer than 255 characters, or the reserved id `*`. A principal is represented as: @@ -105,7 +105,7 @@ A principal is represented as: } ``` -Creating a principal stores only the principal row. 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. +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). @@ -120,9 +120,9 @@ The principal's effective actions are read through `GET /principals/{principalId } ``` -Buckets with an empty effective set are omitted. Hilt computes the result from its own tables on every call and does not consult another service, so the read reflects Hilt's latest committed write. +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. The row stays so that a removed id keeps its `createdAt` and a second `DELETE` or a later `PUT` finds it. +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 @@ -184,13 +184,13 @@ where `s3:*` expands to every action in the policy vocabulary. Hilt returns the **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 stores the document with its bucket and maintains an index from each principal to the buckets whose statements name that principal, making the two principal reads above index lookups. Statements naming `"*"` are indexed at the tenant level. The policy row is deleted with the bucket. +**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. This RFC also deliberately excludes `DeleteBucket` from the policy vocabulary so that bucket lifecycle operations remain service-key-only, matching the ADR's decision that member keys cannot create or delete buckets. A principal-bound key therefore receives `AccessDenied` from both operations. `aws s3 mb` with a member's key fails, and members create buckets through the console. The user-facing S3 documentation needs to state this limitation. +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's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the S3 request to Hilt in `/s3/bucket/create` as it does today, headers included, so the header requires no new Ingot behavior. Hilt decodes it, validates it as `PutBucketPolicy` would, creates the bucket, writes the policy, and issues every key of each principal the policy reaches, with `"*"` expanded to every live principal as a policy write expands it, its delegations over the new bucket. If the policy write or the issuance fails, Hilt deletes the bucket with whatever it had written. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `MalformedPolicy` (400). Ingot caps the request head at 8 KB, leaving roughly 5 KB for the decoded JSON policy under the expected request headers. The console MUST check the encoded request size before sending the create and refuse to submit a request that would exceed Ingot's header limit; an oversized request may otherwise be rejected by Ingot before it reaches Hilt. +A new bucket's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the request to Hilt in `/s3/bucket/create`, headers included. Hilt decodes the document, validates it as `PutBucketPolicy` would, creates the bucket, writes the policy, and issues every key the policy reaches its delegations over the new bucket. If the policy write or the issuance fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `MalformedPolicy` (400). Ingot caps the request head at 8 KB, which leaves roughly 5 KB for the decoded document; the console keeps its documents under that limit. The console includes this header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written with `PutBucketPolicy`, by that client or by the console. @@ -208,9 +208,9 @@ The console includes this header on every bucket it creates. The document names `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, record the principal on the key row, and store `NULL` for both permissions and bucket list. It 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 a `principal` field carrying the `principalId`, and no `permissions` or `buckets`. +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 in the `delegation` table with the tenant's other grants. 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)): +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 { @@ -222,15 +222,15 @@ Hilt MUST also issue the key its delegations and store them in the `delegation` } ``` -For example, if a member's policies grant `s3:PutObject` on `photos` and `s3:GetObject` on `backups`, each of the member's two keys receives six delegations over `photos` and one over `backups`. The stored set for a key and a bucket is always `CommandsFor(effective(principal, bucket))`. Every write below keeps that inside its transaction, so a request that passes the action check always resolves a chain, and a key whose principal reaches no bucket holds no delegations. ucantone gives every delegation a random nonce, so a reissued set never carries a CID that was revoked. The shape is the parent RFC's: a service key scoped to named buckets holds the same rows, derived from its permission list. +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, as today. A principal-bound key name is unique only within its principal, so two members may each hold a key named `laptop`. +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 the `principal` field. A key without it is a service key and 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. +`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, under the advisory lock on the key's delegation set so that a rotation of the key in flight waits for the outcome: a revocation for each of the key's delegations, published in one Swarf request, then the delegations, the row, and the vault entry. Ingot drops that key's per-key cache and no other. +`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`. Hilt enforces no key limit today, and this RFC adds none. +Both key types count toward `accessKeyCount`. This RFC adds no key limit. ### Policy changes and delegation rotation @@ -240,17 +240,16 @@ A principal's first grant on a bucket publishes no revocation because the key pr 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. -**Locking.** Hilt takes no row locks today; this RFC introduces them on four paths. A policy write runs in a single Postgres transaction and takes its locks in this order: the principal rows the new policy names, with `SELECT ... FOR KEY SHARE`; a transaction-scoped advisory lock on the bucket; the bucket row, with `FOR KEY SHARE`; and the policy row, with `FOR UPDATE`. It then locks each affected principal row with `SELECT ... FOR NO KEY UPDATE` before enumerating that principal's keys, and rotating a key takes an advisory lock on that key's delegation set. Hilt publishes revocations while these locks are held and commits only afterwards. The two principal lock modes do not conflict with each other, so a write never waits on its own locks, and both conflict with the `FOR UPDATE` a principal removal takes. A removal locks the principal row only after it has rewritten the policies naming the principal (see [principal removal](#principal-removal)), so no path holds a principal row while it waits for a bucket. +**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: -`/s3/request/authorize` reads the key, principal, and policy rows with `SELECT ... FOR SHARE`, each in a short transaction of its own, and reads no stored delegation. `/s3/bucket/info` reads the key's delegations and then the policy again, and refuses the request with `TemporarilyUnavailable` when the policy changed in between, so a chain is never paired with an action set from another revision. A reader therefore holds at most one lock at a time and waits for at most one write. Key creation reads the principal row with `SELECT ... FOR SHARE` and materializes delegations from the policies visible to that transaction. An authorize request arriving after revocation publication but before commit therefore waits for the commit and is answered from the new policy with the new delegations. Likewise, a key created while a policy write is in flight is either included in the rotation or created from the committed policy. Without these locks, an authorize request during a write would be answered from the old policy with delegations Ingot has seen revoked, and every request from the key would reach Hilt until the commit (see [error recovery](#error-recovery)). +- 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)). -During a write to a bucket's policy, authorize requests for that bucket may wait for one Swarf round trip because all revocations from the write are sent in one request. The write keeps its transaction open while waiting for Swarf, up to 8 seconds; if it has not completed by then, it fails and rolls back. Readers and writers also fail if they cannot acquire their locks within 10 seconds, ensuring that a slow Swarf releases the locks before waiting readers time out. A timed-out policy write returns `ConcurrentChange`, which Ingot renders as `OperationAborted` (409). A timed-out authorize returns `TemporarilyUnavailable`, which Ingot renders as `ServiceUnavailable` (503). +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). -The locks do not cover an authorize request that completed before the policy write acquired its locks but whose response reaches Ingot after the revocation. [Error recovery](#error-recovery) covers that response. - -**Failure.** A write that fails at any step, before or after publishing its revocations, is covered in [error recovery](#error-recovery). - -Deleting a key revokes only that key's delegations. The principal's other keys keep their cached proofs. +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. @@ -260,15 +259,13 @@ Every write that publishes revocations (a policy change, a key's deletion, a buc **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 per-key cache, and records each revoked CID in an in-memory set kept until the next UTC midnight plus clock skew, the same horizon as the cache. On the key's next request Ingot misses the cache, calls Hilt, and receives the same stored delegations. Their CIDs are in the set, so Ingot authorizes the request from the response 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. Ingot handles every revocation the same way. The set only stops Ingot caching a response; Hilt's committed state decides the 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, and ucantone's random nonce gives them new CIDs. The key's next request caches them. The change reaches Ingot one request after the commit. +**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. -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 drops the set and caches the stored delegations again. A retry that commits after that point reaches Ingot at the following midnight. +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. -**A response that outran a write.** An authorize response that left Hilt before a write took its locks can arrive at Ingot after the write's revocations. Its delegations are in the set, so Ingot serves that one request under the old state and caches nothing. Swarf polls its store once per second before emitting a record, so the response must stay in flight longer than that interval for this to occur. +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. -**Restart.** Ingot keeps the set in memory and a restart clears it together with the cache. A restart between a failed commit and its successful retry refills the cache from the stored delegations, and the retry's duplicate revocations emit nothing, so that key sees the change 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 @@ -276,24 +273,24 @@ If the console stops retrying, the old state stays in force and Hilt holds nothi 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 row with `SELECT ... FOR UPDATE`. 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 (each row under its delegation lock, committed as it goes, then the vault entries), sets `deleted_at` on the principal row, and commits, releasing the lock. +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 lock because a policy write holds its bucket while it locks the principals it changes. A removal that held the principal row while it rewrote that bucket's policy would wait on the write while the write waited on it. +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 row remains as a tombstone. 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 on the lock until the removal commits or rolls back, then is refused with `UnknownAccessKey` because the key is gone. +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 clears `deleted_at`. 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. +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's delegations, policy, and bucket row. 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. +`/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. Today the deletion publishes nothing, and a warm key keeps serving from Ingot's cache until the next UTC midnight. +`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 @@ -324,7 +321,7 @@ For a request signed with a principal-bound key, Hilt performs the parent RFC's } ``` -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`. The key, principal, and policy rows are read with a share lock (see [policy changes](#policy-changes-and-delegation-rotation)). The path reads the access key's private key from the vault, as the parent RFC's does. 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. +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. @@ -395,12 +392,12 @@ This RFC adds these failures: | Hilt failure | Meaning | Ingot renders as | | ------------------------ | -------------------------------------------------------------------------- | ---------------------------- | -| `TemporarilyUnavailable` | An authorize could not take its share lock within the lock timeout. | `ServiceUnavailable` (503) | +| `TemporarilyUnavailable` | An authorize could not proceed within its timeout. | `ServiceUnavailable` (503) | | `InvalidBucketPolicy` | A policy document failed validation, in a create header or a `PutBucketPolicy`. | `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 take its locks within the lock timeout. | `OperationAborted` (409) | +| `ConcurrentChange` | A policy write could not complete within its timeout. | `OperationAborted` (409) | The client retries on 503 and 409. @@ -408,7 +405,7 @@ The client retries on 503 and 409. 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 a single request. A ucantone server executes every invocation addressed to it in a request container, and the client library exposes that as `ExecuteBatch`, which returns one receipt per invocation. The Swarf client gains a batch-publish operation that builds one `/ucan/revoke` invocation per delegation and sends them together. Hilt checks every receipt, so any rejected revocation fails the write. A `"*"` policy edit therefore costs one Swarf round trip regardless of how many keys it rotates, although the request size grows with the fan-out (see [open questions](#open-questions)). +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 @@ -418,21 +415,21 @@ For each access key, Ingot caches the data carried by Hilt's authorize response: 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`. Today Ingot answers `GetBucketPolicy` with `NoSuchBucketPolicy` and the other two with 501. +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 records what it revokes.** When Swarf reports a revoked CID, Ingot drops every per-key cache whose proof chains contain a delegation with that CID, including the cached effective action sets, signing key, and tenant, and adds the CID to an in-memory set kept until the next UTC midnight plus clock skew. 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 still enters the set. On a cache miss, Ingot checks the delegations in Hilt's response against the set. 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 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 tenant-wide credential it uses today. 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 with the policy header, bucket deletion, policy reads and writes, background workers, and every member's bucket and object traffic, presigned URLs included. +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 with the policy header, bucket 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, as today. Hilt and Ingot enforce the policies directly for keys bound to a principal, which members mint for their own use. +- 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. @@ -462,7 +459,7 @@ The policy vocabulary is Hilt's S3 permission set excluding the bucket-level act 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`, as they do today, 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. +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. @@ -472,8 +469,8 @@ Every existing key becomes a service key because a key without a principal is ex **Once per Forge network.** -1. Deploy Ingot with the policy operations, the new error mappings, and the revoked set. Ingot already enforces the cached effective action set. Swarf's service needs no deployment; Hilt takes the client library with the batch publish. -2. Deploy Hilt. Its schema migration adds the principal and policy tables and the `principal_id` column on keys. Tenants, buckets, keys, vault entries, and delegations are untouched, and every key keeps authorizing through the parent RFC's path. Principal-bound keys are new, so every one is created with its delegations and nothing is backfilled. +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. @@ -534,17 +531,17 @@ Hilt PR #48 proposed scoping `/s3/bucket/list` to the buckets reachable by a pri ## 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: rows and tenant-key signatures inside the write, and one Swarf request whose size grows with the fan-out. A key's rows number its buckets times the commands its actions map to, up to seven 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. +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. The create header is bound by Ingot's 8 KB request head; `PutBucketPolicy` has no stated 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 from today's key. +- 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 revoked set. +- Swarf's service needs no changes. Ingot's firehose consumer adds only the revocation cache. ## References @@ -633,7 +630,7 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe 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 locks the policy row and the member's principal row, stores the new delegations for each of the member's keys, publishes nothing, and commits the document. The response carries the new `ETag`. +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 @@ -647,13 +644,13 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe #### 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, locks the policy row and the member's principal row, publishes a revocation for each delegation the member's keys hold over `photos` in one Swarf request, deletes those rows, and commits the document. An authorize request for `photos` that arrives meanwhile waits for the commit. +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 row, deletes its keys, sets `deleted_at`, and commits. +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. From 113d4ded33c228957ca3f35115f0ea19ede67903 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Mon, 5 Oct 2026 11:46:17 +0200 Subject: [PATCH 39/40] rfc: write the policy after the create and use AWS field names The console creates a bucket with CreateBucket and then PutBucketPolicy with If-None-Match: *; the x-bucket-policy header moves to alternatives. Policy documents use AWS capitalization: Statement, Sid, Effect, Principal, Action, Allow, Deny. Co-Authored-By: Claude Fable 5.1 --- rfcs/2026-09-forge-s3-tenant-iam.md | 76 ++++++++++++++--------------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index a9717b1..1216b52 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -41,7 +41,7 @@ The parent RFC's roles apply. Swarf is added: ## 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. +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. @@ -136,51 +136,51 @@ A bucket has at most one policy. It is read and written over S3 with a service k 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, limited to the fields Forge evaluates. It is the body of `PutBucketPolicy` and of the `GetBucketPolicy` response: +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": [ + "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 + "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"] + "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`. +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 `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. +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 "*") +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. The ETag is the CID of the DAG-CBOR encoding of that document. Two documents that differ only in order or in duplicates have the same ETag. +**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. The ETag is the CID of the DAG-CBOR encoding of that document. 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. @@ -190,9 +190,7 @@ where `s3:*` expands to every action in the policy vocabulary. Hilt returns the 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's policy is carried on the create request in the Forge-specific `x-bucket-policy` header as base64-encoded JSON; AWS does not define this header. Ingot forwards the request to Hilt in `/s3/bucket/create`, headers included. Hilt decodes the document, validates it as `PutBucketPolicy` would, creates the bucket, writes the policy, and issues every key the policy reaches its delegations over the new bucket. If the policy write or the issuance fails, Hilt deletes the bucket. No bucket outlives a failed write of the policy its create request carried. The header MUST be among the request's `SignedHeaders`; Hilt refuses a create whose policy header is present and unsigned, since otherwise anything on the path could replace the document. A document that fails validation refuses the create with `InvalidBucketPolicy`, which Ingot renders as `MalformedPolicy` (400). Ingot caps the request head at 8 KB, which leaves roughly 5 KB for the decoded document; the console keeps its documents under that limit. - -The console includes this header on every bucket it creates. The document names the member who created the bucket and the organization's current Owners and Admins, each with the actions their role permits. A bucket created without the header, by a generic S3 client holding a service key, has no policy, and only service keys reach it until a policy is written with `PutBucketPolicy`, by that client or by 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 @@ -234,7 +232,7 @@ 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 `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. @@ -294,7 +292,7 @@ A `PUT` for a removed principal's id revives it. The principal returns with no k ## 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, bucket creation gains the policy header, and one new command carries the policy operations. +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` @@ -333,9 +331,7 @@ For a principal-bound key, the chain consists of the bucket root, the bucket-to- ### `/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 reads the `x-bucket-policy` header from the forwarded request when it is present, checks that the header is signed, validates the decoded document as `/s3/bucket/policy` does, creates the bucket, and stores the policy. A failed policy write deletes the bucket (see [bucket creation](#bucket-creation)). An unsigned or invalid document refuses the create with `InvalidBucketPolicy`. Delete removes the policy with the bucket, as described above. +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` @@ -376,7 +372,7 @@ Hilt verifies the signature as `/s3/request/authorize` does and requires a servi ```jsonc { "etag": "bafy...", - "policy": { "statement": [ /* ... */ ] } // GET only + "policy": { "Statement": [ /* ... */ ] } // GET only } ``` @@ -393,7 +389,7 @@ This RFC adds these failures: | Hilt failure | Meaning | Ingot renders as | | ------------------------ | -------------------------------------------------------------------------- | ---------------------------- | | `TemporarilyUnavailable` | An authorize could not proceed within its timeout. | `ServiceUnavailable` (503) | -| `InvalidBucketPolicy` | A policy document failed validation, in a create header or a `PutBucketPolicy`. | `MalformedPolicy` (400) | +| `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) | @@ -413,7 +409,7 @@ Ingot's request path is unchanged: it consults Hilt on a cache miss and otherwis 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. +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`. @@ -423,7 +419,7 @@ Error mapping gains the rows listed under [failures](#failures). `UnknownBucket` ## 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 with the policy header, bucket deletion, policy reads and writes, background workers, and every member's bucket and object traffic, presigned URLs included. +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. @@ -474,7 +470,7 @@ Every existing key becomes a service key because a key without a principal is ex **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 includes `x-bucket-policy` on every bucket it creates and refuses a scoped member's request outside their effective actions. +**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. @@ -518,11 +514,11 @@ Policies could be read and written through `/tenants/{tenantId}/buckets/{bucketN ### A default policy template per tenant -Hilt could store one policy template per tenant and copy it onto each bucket at creation, eliminating the policy from the create request. The console would then have to keep that template synchronized with the organization's roster, creating a second copy of the same statements already written to each bucket. The template also could not naturally include the member who initiated a specific bucket creation. Carrying the policy in the request header sends the document once, at the point where it is needed. +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. -### The policy written after the create +### A policy header on the create request -The console could create the bucket over S3 and then write its policy with `PutBucketPolicy`, responding to the member only after both operations complete. A console failure between those operations, however, could leave a bucket visible in listings but inaccessible to every member, with no event to trigger another policy write. Carrying the policy on the create request makes bucket creation and initial policy installation one operation. +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 @@ -532,7 +528,7 @@ Hilt PR #48 proposed scoping `/s3/bucket/list` to the buckets reachable by a pri 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. The create header is bound by Ingot's 8 KB request head; `PutBucketPolicy` has no stated limit (AWS caps a bucket policy at 20 KB). +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 @@ -621,15 +617,15 @@ The `delegation` table is unchanged. For a principal-bound key, its rows represe #### Create a bucket -1. A member creates `photos` in the console. Fil One builds the policy naming the member and the org's Owners and Admins, and sends `PUT /photos` to Ingot signed with its service key, with the document base64-encoded in the signed `x-bucket-policy` header. -2. Ingot invokes `/s3/bucket/create` with the request. -3. Hilt verifies the signature, checks that the header is signed, decodes and validates the document, and creates the bucket and its policy in one transaction. -4. Ingot adds the bucket to its local database and answers the member. Their next request on `photos` reaches Hilt and is authorized against the policy. +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`. +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. From 9b79061c30ce3af402fb9d931bb89a551612b0e3 Mon Sep 17 00:00:00 2001 From: Srdjan S Date: Mon, 5 Oct 2026 15:46:19 +0200 Subject: [PATCH 40/40] rfc: compute the policy ETag over a DAG-JSON encoding of sets The ETag hashes statements, principals and actions as DAG-JSON maps, so order and duplicates do not change it. Co-Authored-By: Claude Opus 5.5 --- rfcs/2026-09-forge-s3-tenant-iam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rfcs/2026-09-forge-s3-tenant-iam.md b/rfcs/2026-09-forge-s3-tenant-iam.md index 1216b52..78fc485 100644 --- a/rfcs/2026-09-forge-s3-tenant-iam.md +++ b/rfcs/2026-09-forge-s3-tenant-iam.md @@ -180,7 +180,7 @@ where `s3:*` expands to every action in the policy vocabulary. Hilt returns the `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. The ETag is the CID of the DAG-CBOR encoding of that document. Two documents that differ only in order or in duplicates have the same ETag. +**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.