Skip to content

Commit

Permalink
feat: ucan invocation handler (#133)
Browse files Browse the repository at this point in the history
### Context
To enable the gateway to serve content from a specific space, the space
owner must delegate the `space/content/serve/*` capability to the
Gateway. This delegation ensures the Gateway has the authority to serve
the content and log egress events accurately.

This PR introduces a new handler to process POST requests to the
server's root path. The handler acts as a UCAN Invocation handler,
processing `access/delegate` invocations and extracting relevant
delegation proofs. If a delegation proof is valid, it is stored in
Cloudflare KV, allowing other handlers to retrieve and verify the proof
to determine whether content should be served and egress logged.

Note: It doesn't cover the token verification.

### Main Changes
#### **New Functionality**
- Added `withUcanInvocationHandler.js` to process `access/delegate`
invocations:
  - Validates delegation proofs.
- Stores valid proofs in a Cloudflare KV namespace dedicated to `content
serve` delegations.
- Feature Flag: `FF_DELEGATIONS_STORAGE_ENABLED` if enabled, the new
`withDelegationsStorage.js` handler will be used to find delegations in
KV, and the existing `withDelegationsStubs.js` will be disabled.

### Related Issues
- storacha/project-tracking#158
- storacha/project-tracking#160
  • Loading branch information
fforbeck authored Dec 11, 2024
1 parent fa3f480 commit b199bfa
Show file tree
Hide file tree
Showing 24 changed files with 737 additions and 103 deletions.
50 changes: 50 additions & 0 deletions docs/ucan-handler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
### Gateway Content Serve Authorization Flow

```mermaid
flowchart TD
subgraph Client Side
A[User] -->|Creates Space & Authorizes Gateway| B[w3up-client]
end
subgraph Cloudflare Freeway Worker
C[Ucanto Server]
F[Content Server]
end
subgraph Cloudflare KV Store
D[Content Serve Delegations]
end
B -->|UCAN access/delegate| C
C -->E[UCAN Invocation Handler]
E -->|Stores Valid Delegation| D
F -->|Retrieves Delegation| D[Content Serve Delegations]
```

### Explanation
1. **User Interaction**
- The user interacts with the `w3up-client` to create a space and authorize the gateway to serve content.

2. **UCAN Invocation**
- The `w3up-client` sends a UCAN invocation `access/delegate` to the Ucanto Server, providing the delegation details (`{ space, proofs }`).
- The request is processed by the UCAN Invocation Handler in the Cloudflare Freeway Worker.

3. **Validation Steps**
- It validates that the delegation matches the expected capability (`space/content/serve/*`).
- It ensures the proof chain is valid.

4. **Storing Delegation**
- After successful validation, the delegation is stored in the KV Store (`Content Serve Delegations Storage`) for further use.

5. **Content Server Retrieval**
- The Freeway Worker retrieves the validated delegations from the KV Store to serve content for authorized spaces.


### Key Considerations
- **Mitigating DoS Attacks**
- By verifying that the space is provisioned before accepting the delegation, we can reduce the risk of abuse from unauthorized or irrelevant requests.
- We still need to implement this verification in another iteration.
- **Efficiency**
- This additional validation ensures only relevant delegations are processed and stored, minimizing resource waste.
- **Implementation**
- Adding a check against the space provisioning status in the `Ucanto Server` can be done efficiently by querying the space registry or relevant provisioning database.
3 changes: 1 addition & 2 deletions scripts/delegate-serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import sade from 'sade'
import { Schema } from '@ucanto/core'
import { getClient } from '@storacha/cli/lib.js'
import { Space } from '@web3-storage/capabilities'
import * as serve from '../src/capabilities/serve.js'

const MailtoDID =
/** @type {import('@ucanto/validator').StringSchema<`did:mailto:${string}:${string}`, unknown>} */ (
Expand Down Expand Up @@ -51,7 +50,7 @@ sade('delegate-serve.js [space]')

if (proofs.length === 0) {
throw new Error(
`No proofs found. Are you authorized to ${serve.star.can} ${space}?`
`No proofs found. Are you authorized to ${Space.contentServe.can} ${space}?`
)
}

Expand Down
20 changes: 0 additions & 20 deletions src/capabilities/serve.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,5 @@
import { capability, Schema, DID, nullable, string } from '@ucanto/validator'

/**
* "Manage the serving of content owned by the subject Space."
*
* A Principal who may `space/content/serve/*` is permitted to perform all
* operations related to serving content owned by the Space, including actually
* serving it and recording egress charges.
*/
export const star = capability({
can: 'space/content/serve/*',
/**
* The Space which contains the content. This Space will be charged egress
* fees if content is actually retrieved by way of this invocation.
*/
with: DID,
nb: Schema.struct({
/** The authorization token, if any, used for this request. */
token: nullable(string())
})
})

/**
* "Serve content owned by the subject Space over HTTP."
*
Expand Down
19 changes: 15 additions & 4 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
withCorsHeaders,
withContentDispositionHeader,
withErrorHandler,
createWithHttpMethod,
createWithHttpMethod as withHttpMethods,
withCdnCache,
withParsedIpfsUrl,
withFixedLengthStream,
Expand All @@ -26,6 +26,8 @@ import {
withEgressClient,
withAuthorizedSpace,
withLocator,
withUcanInvocationHandler,
withDelegationsStorage,
withDelegationStubs
} from './middleware/index.js'
import { instrument } from '@microlabs/otel-cf-workers'
Expand All @@ -48,17 +50,26 @@ import { NoopSpanProcessor } from '@opentelemetry/sdk-trace-base'
* The middleware stack
*/
const middleware = composeMiddleware(
// Prepare the Context
// Prepare the Context for all types of requests
withCdnCache,
withContext,
withCorsHeaders,
withVersionHeader,
withErrorHandler,
withGatewayIdentity,
withDelegationsStorage,

// Handle UCAN invocations (POST requests only)
withUcanInvocationHandler,

// Handle Content Serve requests (GET and HEAD requests)
withHttpMethods('GET', 'HEAD'),

// Prepare the Context for other types of requests
withParsedIpfsUrl,
createWithHttpMethod('GET', 'HEAD'),
withAuthToken,
withLocator,
withGatewayIdentity,

// TODO: replace this with a handler to fetch the real delegations
withDelegationStubs,

Expand Down
2 changes: 2 additions & 0 deletions src/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ export { withEgressTracker } from './withEgressTracker.js'
export { withEgressClient } from './withEgressClient.js'
export { withDelegationStubs } from './withDelegationStubs.js'
export { withGatewayIdentity } from './withGatewayIdentity.js'
export { withUcanInvocationHandler } from './withUcanInvocationHandler.js'
export { withDelegationsStorage } from './withDelegationsStorage.js'
28 changes: 13 additions & 15 deletions src/middleware/withAuthorizedSpace.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { Verifier } from '@ucanto/principal'
import { ok, access, Unauthorized } from '@ucanto/validator'
import { HttpError } from '@web3-storage/gateway-lib/util'
import * as serve from '../capabilities/serve.js'
import { SpaceDID } from '@web3-storage/capabilities/utils'

/**
* @import * as Ucanto from '@ucanto/interface'
* @import { IpfsUrlContext, Middleware } from '@web3-storage/gateway-lib'
* @import { LocatorContext } from './withLocator.types.js'
* @import { AuthTokenContext } from './withAuthToken.types.js'
* @import { SpaceContext, DelegationsStorageContext, DelegationProofsContext } from './withAuthorizedSpace.types.js'
* @import { SpaceContext } from './withAuthorizedSpace.types.js'
* @import { DelegationsStorageContext } from './withDelegationsStorage.types.js'
* @import { GatewayIdentityContext } from './withGatewayIdentity.types.js'
* @import { DelegationProofsContext } from './withAuthorizedSpace.types.js'
*/

/**
Expand Down Expand Up @@ -46,7 +49,7 @@ export function withAuthorizedSpace (handler) {
ctx.authToken === null

if (shouldServeLegacy) {
return handler(request, env, { ...ctx, space: null })
return handler(request, env, ctx)
}

// These Spaces all have the content we're to serve, if we're allowed to.
Expand All @@ -58,14 +61,14 @@ export function withAuthorizedSpace (handler) {
// First space to successfully authorize is the one we'll use.
const { space: selectedSpace, delegationProofs } = await Promise.any(
spaces.map(async (space) => {
const result = await authorize(space, ctx)
const result = await authorize(SpaceDID.from(space), ctx)
if (result.error) throw result.error
return result.ok
})
)
return handler(request, env, {
...ctx,
space: selectedSpace,
space: SpaceDID.from(selectedSpace),
delegationProofs,
locator: locator.scopeToSpaces([selectedSpace])
})
Expand Down Expand Up @@ -97,20 +100,15 @@ export function withAuthorizedSpace (handler) {
* authorizing delegations in the
* {@link DelegationsStorageContext.delegationsStorage}.
*
* @param {Ucanto.DID} space
* @param {import('@web3-storage/capabilities/types').SpaceDID} space
* @param {AuthTokenContext & DelegationsStorageContext & GatewayIdentityContext} ctx
* @returns {Promise<Ucanto.Result<{space: Ucanto.DID, delegationProofs: Ucanto.Delegation[]}, Ucanto.Failure>>}
* @returns {Promise<Ucanto.Result<{space: import('@web3-storage/capabilities/types').SpaceDID, delegationProofs: Ucanto.Delegation[]}, Ucanto.Failure>>}
*/
const authorize = async (space, ctx) => {
// Look up delegations that might authorize us to serve the content.
const relevantDelegationsResult = await ctx.delegationsStorage.find({
audience: ctx.gatewayIdentity.did(),
can: serve.transportHttp.can,
with: space
})

const relevantDelegationsResult = await ctx.delegationsStorage.find(space)
if (relevantDelegationsResult.error) return relevantDelegationsResult

const delegationProofs = relevantDelegationsResult.ok
// Create an invocation of the serve capability.
const invocation = await serve.transportHttp
.invoke({
Expand All @@ -120,7 +118,7 @@ const authorize = async (space, ctx) => {
nb: {
token: ctx.authToken
},
proofs: relevantDelegationsResult.ok
proofs: delegationProofs
})
.delegate()

Expand All @@ -138,7 +136,7 @@ const authorize = async (space, ctx) => {
return {
ok: {
space,
delegationProofs: relevantDelegationsResult.ok
delegationProofs
}
}
}
37 changes: 8 additions & 29 deletions src/middleware/withAuthorizedSpace.types.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,23 @@
import * as Ucanto from '@ucanto/interface'
import { Context as MiddlewareContext } from '@web3-storage/gateway-lib'

export interface DelegationsStorageContext extends MiddlewareContext {
delegationsStorage: DelegationsStorage
}

import { SpaceDID } from '@web3-storage/capabilities/types'
export interface DelegationProofsContext extends MiddlewareContext {
/**
* The delegation proofs to use for the egress record
* The proofs must be valid for the space and the owner of the space
* must have delegated the right to the Gateway to serve content and record egress traffic.
* The `space/content/serve/*` capability must be granted to the Gateway Web DID.
*/
delegationProofs: Ucanto.Delegation[]
delegationProofs: Ucanto.Delegation<Ucanto.Capabilities>[]
}

export interface SpaceContext extends MiddlewareContext {
space: Ucanto.DID | null
}

// TEMP: https://github.com/storacha/blob-fetcher/pull/13/files
declare module '@web3-storage/blob-fetcher' {
interface Site {
space?: Ucanto.DID
}
}

// TEMP

export interface Query {
audience?: Ucanto.DID
can: string
with: Ucanto.Resource
}

export interface DelegationsStorage {
/**
* find all items that match the query
* The SpaceDID of the space that is authorized to serve the content from.
* If the space is not authorized, the request is considered a legacy request - which is served by default.
* The egress is not recorded for legacy requests because the space is unknown.
* Eventually, legacy requests will be aggressively throttled, forcing the users to migrate to authorized spaces.
* Then this field will become required and the legacy behavior will be removed.
*/
find: (
query: Query
) => Promise<Ucanto.Result<Ucanto.Delegation[], Ucanto.Failure>>
space?: SpaceDID
}
17 changes: 14 additions & 3 deletions src/middleware/withDelegationStubs.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { Delegation, Schema } from '@ucanto/core'

/**
* @import {
Environment,
* Middleware,
* Context as MiddlewareContext
* } from '@web3-storage/gateway-lib'
* @import { DelegationsStorageContext } from './withAuthorizedSpace.types.js'
* @import { DelegationsStorageContext } from './withDelegationsStorage.types.js'
* @import { LocatorContext } from './withLocator.types.js'
* @import { GatewayIdentityContext } from './withGatewayIdentity.types.js'
*/
Expand All @@ -23,11 +24,18 @@ import { Delegation, Schema } from '@ucanto/core'
* Middleware<
* MiddlewareContext & LocatorContext & GatewayIdentityContext & DelegationsStorageContext,
* MiddlewareContext & LocatorContext & GatewayIdentityContext,
* {}
* Environment & { FF_DELEGATIONS_STORAGE_ENABLED: string }
* >
* )}
*/
export const withDelegationStubs = (handler) => async (request, env, ctx) => {
if (env.FF_DELEGATIONS_STORAGE_ENABLED === 'true') {
// @ts-expect-error: If FF_DELEGATIONS_STORAGE_ENABLED is true, the context
// will have the delegationsStorage created by the withDelegationsStorage
// middleware. So we can skip the stubbing.
return handler(request, env, ctx)
}

const stubSpace = new URL(request.url).searchParams.get('stub_space')
const stubDelegations = await Promise.all(
new URL(request.url).searchParams
Expand All @@ -48,7 +56,10 @@ export const withDelegationStubs = (handler) => async (request, env, ctx) => {

return handler(request, env, {
...ctx,
delegationsStorage: { find: async () => ({ ok: stubDelegations }) },
delegationsStorage: {
find: async () => ({ ok: stubDelegations }),
store: async () => ({ ok: {} })
},
locator:
stubSpace && Schema.did({ method: 'key' }).is(stubSpace)
? ctx.locator.scopeToSpaces([stubSpace])
Expand Down
Loading

0 comments on commit b199bfa

Please sign in to comment.