Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Egress is authorized with UCAN (stubbed) #126

Merged
merged 12 commits into from
Nov 6, 2024
3,381 changes: 2,943 additions & 438 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@
"@types/mocha": "^10.0.9",
"@types/node-fetch": "^2.6.11",
"@types/sinon": "^17.0.3",
"@ucanto/principal": "^8.1.0",
"@ucanto/principal": "^9.0.1",
"@web3-storage/content-claims": "^5.0.0",
"@web3-storage/public-bucket": "^1.1.0",
"@web3-storage/upload-client": "^16.1.1",
"@web3-storage/w3cli": "^7.8.2",
"carstream": "^2.1.0",
"chai": "^5.1.1",
"esbuild": "^0.18.20",
Expand Down
4 changes: 4 additions & 0 deletions scripts/delegate-serve.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
declare module '@web3-storage/w3cli/lib.js' {
import { Client } from '@web3-storage/w3up-client'
export declare function getClient(): Promise<Client>
}
38 changes: 38 additions & 0 deletions scripts/delegate-serve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import sade from 'sade'
import { getClient } from '@web3-storage/w3cli/lib.js'
import * as ed25519 from '@ucanto/principal/ed25519'
import * as serve from '../src/capabilities/serve.js'

const cli = sade('delegate-serve.js <space> [token]')

cli
.describe(
`Delegates ${serve.star.can} to the Gateway for <space>, with an optional token. Outputs a base64url string suitable for the stub_delegation query parameter. Pipe the output to pbcopy or similar for the quickest workflow.`
)
.action(async (space, token) => {
const client = await getClient()

const gatewayIdentity = (await ed25519.Signer.generate()).withDID(
'did:web:w3s.link'
)

const delegation = await serve.star.delegate({
issuer: client.agent.issuer,
audience: gatewayIdentity,
with: space,
nb: { token: token ?? null },
expiration: Infinity,
proofs: client.proofs([
{
can: serve.star.can,
with: space
}
])
})

const carResult = await delegation.archive()
if (carResult.error) throw carResult.error
process.stdout.write(Buffer.from(carResult.ok).toString('base64url'))
})

cli.parse(process.argv)
51 changes: 51 additions & 0 deletions src/capabilities/serve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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({
Copy link
Member

Choose a reason for hiding this comment

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

suggestion: I've released the new version of the w3up capabilities package in case you want to use the new space/content/serve/* from that lib.

Copy link
Member Author

@Peeja Peeja Nov 5, 2024

Choose a reason for hiding this comment

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

Ooh, excellent, thanks!

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."
*
* A Principal who may `space/content/serve/transport/http` is permitted to
* serve any content owned by the Space, in the manner of an [IPFS Gateway]. The
* content may be a Blob stored by a Storage Node, or indexed content stored
* within such Blobs (ie, Shards).
*
* Note that the args do not currently specify *what* content should be served.
* Invoking this command does not currently *serve* the content in any way, but
* merely validates the authority to do so. Currently, the entirety of a Space
* must use the same authorization, thus the content does not need to be
* identified. In the future, this command may refer directly to a piece of
* content by CID.
*
* [IPFS Gateway]: https://specs.ipfs.tech/http-gateways/path-gateway/
*/
export const transportHttp = capability({
can: 'space/content/serve/transport/http',
/**
* 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())
})
})
8 changes: 5 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ import {
withAuthToken,
withCarBlockHandler,
withRateLimit,
withNotFound,
withEgressTracker,
withAuthorizedSpace,
withLocator,
withEgressTracker
withDelegationStubs
} from './middleware/index.js'
import { instrument } from '@microlabs/otel-cf-workers'
import { NoopSpanProcessor } from '@opentelemetry/sdk-trace-base'
Expand Down Expand Up @@ -56,6 +57,7 @@ const handler = {
createWithHttpMethod('GET', 'HEAD'),
withAuthToken,
withLocator,
withDelegationStubs,

// Rate-limit requests
withRateLimit,
Expand All @@ -65,7 +67,7 @@ const handler = {

// Fetch data
withCarBlockHandler,
withNotFound,
withAuthorizedSpace,
withContentClaimsDagula,
withFormatRawHandler,
withFormatCarHandler,
Expand Down
3 changes: 2 additions & 1 deletion src/middleware/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { withCarBlockHandler } from './withCarBlockHandler.js'
export { withContentClaimsDagula } from './withContentClaimsDagula.js'
export { withRateLimit } from './withRateLimit.js'
export { withVersionHeader } from './withVersionHeader.js'
export { withNotFound } from './withNotFound.js'
export { withAuthorizedSpace } from './withAuthorizedSpace.js'
export { withLocator } from './withLocator.js'
export { withEgressTracker } from './withEgressTracker.js'
export { withDelegationStubs } from './withDelegationStubs.js'
5 changes: 2 additions & 3 deletions src/middleware/withAuthToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@
* } from '@web3-storage/gateway-lib'
* @import {
* Environment,
* InContext,
* OutContext,
* AuthTokenContext,
* } from './withAuthToken.types.js'
*/

/**
* Finds an authentication token in the URL query parameters or the
* `Authorization` header and adds it to the context as `authToken`.
*
* @type {Middleware<OutContext<MiddlewareContext>, InContext, Environment>}
* @type {Middleware<AuthTokenContext, MiddlewareContext, Environment>}
*/
export function withAuthToken (handler) {
return async (req, env, ctx) => {
Expand Down
4 changes: 2 additions & 2 deletions src/middleware/withAuthToken.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {

export interface Environment extends MiddlewareEnvironment {}

export interface InContext extends MiddlewareContext {}
export interface AuthTokenContextIn extends MiddlewareContext {}

export type OutContext<IncomingContext> = IncomingContext & {
export interface AuthTokenContext extends MiddlewareContext {
authToken: string | null
}
154 changes: 154 additions & 0 deletions src/middleware/withAuthorizedSpace.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
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 * as Ucanto from '@ucanto/interface'
* @import { Locator } from '@web3-storage/blob-fetcher'
* @import { IpfsUrlContext, Middleware } from '@web3-storage/gateway-lib'
* @import { LocatorContext } from './withLocator.types.js'
* @import { AuthTokenContext } from './withAuthToken.types.js'
* @import { SpaceContext, DelegationsStorageContext } from './withAuthorizedSpace.types.js'
*/

/**
* Attempts to locate the {@link IpfsUrlContext.dataCid}. If it's able to,
* attempts to authorize the request to access the data.
*
* @throws {HttpError} (404) If the locator tells us the data is not found, or
* if no located space is one the request is authorized to access.
* @throws {Error} If the locator fails in any other way.
* @type {(
* Middleware<
* LocatorContext & IpfsUrlContext & AuthTokenContext & DelegationsStorageContext & SpaceContext,
* LocatorContext & IpfsUrlContext & AuthTokenContext & DelegationsStorageContext,
* {}
* >
* )}
*/
export function withAuthorizedSpace (handler) {
return async (request, env, ctx) => {
const { locator, dataCid } = ctx
const locRes = await locator.locate(dataCid.multihash)
if (locRes.error) {
if (locRes.error.name === 'NotFound') {
throw new HttpError('Not Found', { status: 404, cause: locRes.error })
}
throw new Error(`failed to locate: ${dataCid}`, { cause: locRes.error })
}

// Legacy behavior: Site results which have no Space attached are from
// before we started authorizing serving content explicitly. For these, we
// always serve the content, but only if the request has no authorization
// token.
const shouldServeLegacy =
locRes.ok.site.some((site) => site.space === undefined) &&
ctx.authToken === null

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

// These Spaces all have the content we're to serve, if we're allowed to.
const spaces = locRes.ok.site
.map((site) => site.space)
.filter((s) => s !== undefined)

try {
// First space to successfully authorize is the one we'll use.
const space = await Promise.any(
spaces.map(async (space) => {
const result = await authorize(space, ctx)
if (result.error) throw result.error
return space
})
)
return handler(request, env, {
...ctx,
space,
locator: spaceScopedLocator(locator, space)
})
} catch (error) {
// If all Spaces failed to authorize, throw the first error.
if (
error instanceof AggregateError &&
error.errors.every((e) => e instanceof Unauthorized)
) {
throw new HttpError('Not Found', { status: 404, cause: error })
} else {
throw error
}
}
}
}

/**
* Authorizes the request to serve content from the given Space. Looks for
* authorizing delegations in the
* {@link DelegationsStorageContext.delegationsStorage}.
*
* @param {Ucanto.DID} space
* @param {AuthTokenContext & DelegationsStorageContext} ctx
* @returns {Promise<Ucanto.Result<{}, 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
})

if (relevantDelegationsResult.error) return relevantDelegationsResult

// Create an invocation of the serve capability.
const invocation = await serve.transportHttp
.invoke({
issuer: ctx.gatewayIdentity,
audience: ctx.gatewayIdentity,
with: space,
nb: {
token: ctx.authToken
},
proofs: relevantDelegationsResult.ok
})
.delegate()

// Validate the invocation.
const accessResult = await access(invocation, {
capability: serve.transportHttp,
authority: ctx.gatewayIdentity,
principal: Verifier,
validateAuthorization: () => ok({})
})

if (accessResult.error) {
return accessResult
}

return { ok: {} }
}

/**
* Wraps a {@link Locator} and locates content only from a specific Space.
*
* @param {Locator} locator
* @param {Ucanto.DID} space
* @returns {Locator}
*/
const spaceScopedLocator = (locator, space) => ({
locate: async (digest) => {
const locateResult = await locator.locate(digest)
if (locateResult.error) {
return locateResult
} else {
return {
ok: {
...locateResult.ok,
site: locateResult.ok.site.filter((site) => site.space === space)
}
}
}
}
})
36 changes: 36 additions & 0 deletions src/middleware/withAuthorizedSpace.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as Ucanto from '@ucanto/interface'
import { Context as MiddlewareContext } from '@web3-storage/gateway-lib'

export interface DelegationsStorageContext extends MiddlewareContext {
/** The identity of the gateway itself */
gatewayIdentity: Ucanto.Signer
delegationsStorage: DelegationsStorage
}

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
*/
find: (
query: Query
) => Promise<Ucanto.Result<Ucanto.Delegation[], Ucanto.Failure>>
}
Loading
Loading