Skip to content

rfc: Forge S3 tenant management#8

Open
alanshaw wants to merge 6 commits into
mainfrom
ash/rfc/forge-s3-tenant-management
Open

rfc: Forge S3 tenant management#8
alanshaw wants to merge 6 commits into
mainfrom
ash/rfc/forge-s3-tenant-management

Conversation

@alanshaw

@alanshaw alanshaw commented Jun 3, 2026

Copy link
Copy Markdown
Member

A short proposal for tenant management in the Forge S3 facade project.

📖 Preview

@alanshaw alanshaw requested a review from a team June 3, 2026 13:55
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated

After a bucket is created, a delegation to the tenant MUST be issued and stored. The delegation MUST grant the tenant full authority over the bucket (issuer = bucket, audience = tenant, subject = bucket, command = "/"). See [top](https://github.com/ucan-wg/spec#-aka-top).

Ingot MUST then issue a `/provider/add` invocation to Sprue to register the bucket/space and assert the bucket's ownership by the tenant.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What are the fields of this invocation? This says Ingot issues it, but /provider/add's subject is the account, which I would assume is the tenant here. Has the tenant delegated this to Ingot? (I think this is meant to be answered below, but I wasn't certain after reading it.)

Comment thread rfcs/2026-06-forge-s3-tenant-management.md
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated

@hannahhoward hannahhoward left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused -- why is Ingot doing any of this? Tenant keys should live in Sprue / centralized upload service, created directly in the website GUI.

I believe ingot should just maintain a mapping of access-key-id to ucan delegations

It should be built in the moment upon receiving a request with an associated access key.

If the access-key is in the table used the cached delegation. If not, dial home to Sprue with an invocation like "access/retrieve" or something. "access/retrieve" would return a 24hour UCAN delegation and a 24 hour signing key (signing keys are by nature 24hours expiration in the sigv4 spec)

Of note: don't hold secret keys inside ingot.

@alanshaw

alanshaw commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

I'm confused -- why is Ingot doing any of this? Tenant keys should live in Sprue / centralized upload service, created directly in the website GUI.

That did occur to me shortly before I opened the PR but I didn't have time to edit/suggest that as an alternative. I think most of the proposal here would apply but I just need to work it through.

Of note: don't hold secret keys inside ingot.

Okay I get it 😆


```sh
accessKeyId: z6Mkve2hqQVMc4qGyHJmn29xp8LX6LfeGkJMryGnCEkpsPqo
secretAccessKey: MgCaxva2aIeovfSx6aI55lSjGwhVI68GxAmo2rGlIVJ6DbQ==

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we prefix the secret with something like f1_ to make it clear this is an FilOne secret?

Can we use a different encoding than Base64 to avoid characters like +, / and = - these characters are not HTTP safe and must be often encoded in transit. Proposal: Base64URL with no padding.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to prefix? Also why would anyone put the secret in a URL?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to prefix?

It's a good practice that is being adopted by companies like Slack, Stripe, GitHub and others.

Access tokens are prefixed to make them easier to identify and distinguish from other types of data, which enhances security by reducing the chances of false positives during token detection. This design choice also improves readability and usability when handling tokens.

See also:

Also why would anyone put the secret in a URL?

Right, that was not a good argument.

What I am looking for: a secret token should contain only word characters, so that double-clicking on a token selects the entire secret.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an RFC proposing a tenant-management model for the Forge S3 facade (Ingot), introducing Hilt as a centralized service to manage tenants, buckets/spaces, S3 access keys, and UCAN delegations used to authorize SigV4/SigV4a requests.

Changes:

  • Adds an RFC describing Hilt’s Tenant API for tenant/bucket/access-key creation and delegation storage.
  • Specifies a UCAN API (/aws/request/authorize) for authorizing AWS API requests and returning derived signing keys + proof chains.
  • Documents alternative approaches and references relevant AWS/SigV4a resources.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
Comment thread rfcs/2026-06-forge-s3-tenant-management.md Outdated
alanshaw and others added 2 commits June 16, 2026 19:07
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

@hannahhoward hannahhoward left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main blocking is about the use of SigV4a


We talked about Ingot creating bucket keys, delegating [top](https://github.com/ucan-wg/spec#-aka-top) (`/`) access to the tenant and storing them in the upload service with `/access/delegate`.

This is not possible for the reason that `/access/delegate` requires the subject (space/bucket) to have been provisioned by the tenant - something that Ingot is not authorized to do (it never sees the tenant private key so cannot sign a `/provider/add` invocation).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can provider/add not be delegated from tenant key to regional provider DID?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but that raises a lot of questions for me:

  • How is this delegation communicated to Ingot? It is on-demand or a one time thing at setup?
    • For on-demand we'd have to create an API for that on Hilt, have Ingot call it to retrieve the delegation and provide some params to verify the request is legit (comes from a valid access key).
    • If it's one-time at setup then we have to build an setup step that calls the delegator(?) or rely on engineers to manually issue a delegation and have the operator add it to config.
    • Also, a one-time at setup delegation would have to be allowed to provision any space, since we don't know ahead of time what the DID is. I know this is a trusted service but IMO we should limit trust where possible and I think allowing it to provision any space is too much.
  • How does Hilt know when to /access/claim? I assume on the first read/write after bucket creation?
    • I think things are simpler if we implemented something like /access/delegate on Hilt and have it only accept delegations addressed to itself. Maybe that's an option?
  • How does Hilt remove the delegation from Sprue when a bucket is deleted? There's currently no facility for this in Sprue.

On the whole it feels like a lot more work to go this route...


##### Result

A successful authorization will return a Sigv4a derived private key that can be used to sign requests, and a set of delegations for that key that allows access to the Forge network.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there’s a misunderstanding with the role of signing keys.

Let’s talk about SigV4 and SigV4a. Basically these are both schemes from AWS to achieve exactly the function we’re doing in our forge deployment with hilt and ingot:

  • The secret access key is only given to the client and stored in a single multiregion IAM service analogous to hilt.
  • In both SigV4 and SigV4a, the client uses the secret access key to generate a signature on each request.
  • The regional provider receives a value that can be used to VERIFY the signature on a request, but with a limited scope.
    • In the case of SigV4, this is the “signing key” which is an HMAC symmetric derivation from the secret access key containing the date and some other parameters (including the region) — because it's a symmetric derivation the provider could technically use it to sign it's own SigV4 requests for a 24 hour period, but it is only used by the regional provider to verify requests to the region from clients without calling home again for 24hours
    • In the case of SigV4A, an EDCSA public/private key pair is derived with a key derivation function from the secret access key, and only the public key is sent to the regional provider. The regional provider can only use this public key to VERIFY requests, never to sign them. It's not 100% clear from docs if the key is date scoped.

Either way, the decision to use SigV4 vs SigV4a is made on the client. We have to support both.

Moreover, we should never be sending SigV4a private keys over the wire -- there is no need for a provider to generate additional requests. Instead, if we need UCAN delegations, we can just delegate to the regional provider's public DID with a 24 hour scope.

So then key section would look like this:

type KeyKind enum {
   | SigV4
   | SigV4a
}

type VerificationKey struct {
   kind KeyKind
   verificationData bytes
}

type AuthorizeOK struct {
  spaceDID DIDBytes
  keys        { String: [VerificationKey] }
  delegations { String: [Link] }
}

Except here key is an access-key-id

@bajtos bajtos Jun 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead, if we need UCAN delegations, we can just delegate to the regional provider's public DID with a 24 hour scope.

IIUC, in this model, the requests from Ingot to Sprue/Piri will be using the provider's public DID as the invoker identity.

As a result, we will lose the audit trail about who made the original request - Sprue & Piri will see all requests as invoked by Ingot, not by the S3 Access Key. Although, if the delegation chain in the UCAN ticket differs depending on which S3 Access Key was used to sign the S3 request, then we would still be able to differentiate invocations received by Sprue/Piri in relation to the S3 Access Key invoked.

I don't know whether it's a relevant requirement for Sprue and Piri to be able to tell if two requests coming from Ingot were signed by the same S3 Access Key or not, so perhaps it's not something we need to worry about.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there’s a misunderstanding with the role of signing keys.

I guess so - I thought the idea was to issue invocations from a key derived from the access key for audit purposes. In this proposal a delegation from the access key will be present in the delegation chain - is that good enough? I think so - having the access key in the delegation chain makes it easy to delete it, you just revoke the access key delegation and then you've cancelled all invocations that might use it.

I had overlooked the case where a second request would need to use a cached sigv4 key to verify the request signature and so, since the client is in charge of generating the sigv4 we'd have to return the same type the client used...

I'm mildly worried about delegating directly to Ingot though - that gives the ingot key a LOT of power over all buckets it is managing. I would prefer if the key used to issue invocations on Forge were at least unique per bucket. Thinking about it - that could be something that Ingot creates rather than Hilt and could be added later. I'll update the RFC to specify that the returned delegations should target the issuer of the /aws/request/authorize invocation - which will be Ingot, but could in the future be a key ingot generates per bucket or something.

| `s3:DeleteObjectVersion` | `/blob/remove`, `/upload/remove` |
| `s3:ListBucket` | `/blob/list`, `/upload/list` |
| `s3:ListBucketVersions` | `/content/retrieve` |
| `s3:CreateBucket` | n/a |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not 100% clear how these last 3 operations work through /aws/request/authorize


### Alternative for tracking IAM role based permissions

An alternative to modeling S3 permissions as UCAN delegations would be to have a separate store that maps access keys to their S3 permission set. The `/aws/request/authorize` invocation would need to return this list in the response, so that Hilt can determine whether the access key is permitted to perform the requested S3 action.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is UCAN just a modelling language here? I continue to wonder whether we shouldn't just use this simpler alternative.


Hilt is configured with a list of Ingot service DIDs that are allowed to call its UCAN API. It provides a single UCAN command that may be invoked:

#### `/aws/request/authorize`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this shouldn't be broken up into a lot of requests with different caching properties -- it's doing a whole lot.

specifically:
/aws/access-key/authorize
-> request contains:
-> access key id
-> sends back:
-> 24hr signing/verification keys
-> all delegations from tenant to regional provider did through the access key id good for 24 hours

/s3/bucket/info
-> request contains
-> bucket name
-> sends back
-> space did
-> delegation from space did to tenant

There are just different caching properties here that might mean less work to do?

| Hilt | Service for managing tenants of Ingot and their secret keys. |
| Ingot | An S3 facade typically co-located with a Forge Piri node. |
| Piri | A Forge network storage node. |
| Tenant | A Fil One organization. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"A Fil One organization" can be potentially misleading - aren't we the Fil One organization?

Suggestion:

A Fil One customer account (organization).


## Hilt - an S3 tenant management service

A trusted centralized service for tenant management exists so that storing secrets is not a requirement for Ingot deployments. Hilt implements the [Tenant API](https://github.com/fil-one/fil-one/blob/main/docs/architectural-decisions/2026-04-service-orchestrator-management-api.md), provides a UCAN API for retrieving proof chains for invocations into the Forge network and speaks to the Forge upload service.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decision record may not be the best resource for the tenant management API docs. Have you considered linking to README instead?

https://github.com/fil-one/fil-one/blob/main/docs/service-orchestrator-integration/README.md


### Tenant API

Hilt MUST be configured with a pre-shared bearer key allowing only the **Fil One service** to call its [Tenant API](https://github.com/fil-one/fil-one/blob/main/docs/architectural-decisions/2026-04-service-orchestrator-management-api.md).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we mention the requirement that Hilt must support rotation of this pre-shared bearer key? I.e. to support more than one bearer key being active at any time, plus methods for creating and revoking keys.


Tenant keys MUST be cryptographic key pairs, they MUST also be `did:plc` keys, allowing them to be rotated if necessary.

After a tenant key has been created, the Ingot service MUST issue a `/account/add` invocation to Sprue to register the account. Note: this is a new capability that does not exist at time of writing. The means of obtaining authority (delegation) needed for Ingot to invoke this capability is out of scope of this document.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which component creates a new tenant key - is it Hilt or Ingot? I assume Ingot should not have access to tenant keys, therefore a new key must be created by Hilt. If that's the case, then we need either:

  • Hilt to register the new key with Ingot before Ingot can issue a /account/add invocation to Sprue,
  • or let Hilt issue the /account/add invocation to Sprue.

Thoughts?


Hilt MUST create and store a secret key per S3 access key.

Access keys MUST be cryptographic keys. They SHOULD be ed25519 keys. The `accessKeyId` is the ed25519 public key, encoded as a DID but with `did:key:` prefix removed. The `secretAccessKey` is the 32 byte ed25519 private key, prefixed with multiformat varint for ed25519 (`0x1300`) and encoded using multibase `base64url`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also prepend a multiformat varint for base64url encoding (U+0075/u)?

The example in the document a few lines later shows a secret access key string starting with the u character, so I think the multibase prefix is already prepended by the multibase.Encode() call; so perhaps I just misunderstood what the RFC is saying.

Is it worth clarifying the RFC text here?


To facilitate authorization **S3 IAM role based permissions are modeled as UCAN delegations**, where the AWS "resource" is the UCAN subject, the "action" is the UCAN command, and the "condition" is the UCAN policy. This allows us to use existing machinery to validate an access key is permitted to perform an action.

Actions are mapped to commands by lowercasing, replacing ":" with "/" and prefixing with "/". e.g. `s3:GetObject` becomes `/s3/getobject`. This makes actions compliant with the rules for [command segment structure](https://github.com/ucan-wg/spec#segment-structure).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a bit concerned about the readability of command names like /s3/putobjectlegalhold. Have you considered using kebab-case or snake-case?

  • /s3/put_object_legal_hold
  • /s3/put-object-legal-hold


##### Result

A successful authorization will return a Sigv4a derived private key that can be used to sign requests, and a set of delegations for that key that allows access to the Forge network.

@bajtos bajtos Jun 17, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead, if we need UCAN delegations, we can just delegate to the regional provider's public DID with a 24 hour scope.

IIUC, in this model, the requests from Ingot to Sprue/Piri will be using the provider's public DID as the invoker identity.

As a result, we will lose the audit trail about who made the original request - Sprue & Piri will see all requests as invoked by Ingot, not by the S3 Access Key. Although, if the delegation chain in the UCAN ticket differs depending on which S3 Access Key was used to sign the S3 request, then we would still be able to differentiate invocations received by Sprue/Piri in relation to the S3 Access Key invoked.

I don't know whether it's a relevant requirement for Sprue and Piri to be able to tell if two requests coming from Ingot were signed by the same S3 Access Key or not, so perhaps it's not something we need to worry about.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants