Skip to content

Commit

Permalink
feat!: content serve authorization (storacha#1590)
Browse files Browse the repository at this point in the history
To enable a gateway to serve content from a specific space, we must
ensure that the space owner delegates the `space/content/serve/*`
capability to the Gateway. This delegation allows the Gateway to serve
content and log egress events appropriately.

I created a new function `authorizeContentServe` for this implementation
and included it in the `createSpace` flow. This is a breaking change
because now the user is forced to provide the DIDs of the Content Serve
services, and the connection, or skip the authorization flow.

Additionally, with the `authorizeContentServe` function, we can
implement a feature in the Console App that enables users to explicitly
authorize the Freeway Gateway to serve content from existing/legacy
spaces.

- **New Functionality:** Added a new function, `authorizeContentServe`,
in the `w3up-client` module to facilitate the delegation process.
Integrated it with the `createdSpace` flow.
- **Testing:** Introduced test cases to verify the authorization of
specified gateways.
- **Fixes:** Resolved issues with previously broken test cases (Egress
Record).

- storacha/project-tracking#158
- storacha/project-tracking#160
  • Loading branch information
fforbeck committed Dec 11, 2024
1 parent 1870202 commit 0d4513d
Show file tree
Hide file tree
Showing 22 changed files with 561 additions and 76 deletions.
24 changes: 14 additions & 10 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,6 @@ export type UsageReport = InferInvokedCapability<typeof UsageCaps.report>
export type UsageReportSuccess = Record<ProviderDID, UsageData>
export type UsageReportFailure = Ucanto.Failure

export type EgressRecord = InferInvokedCapability<typeof SpaceCaps.egressRecord>
export type EgressRecordSuccess = {
space: SpaceDID
resource: UnknownLink
bytes: number
servedAt: ISO8601Date
cause: UnknownLink
}
export type EgressRecordFailure = ConsumerNotFound | Ucanto.Failure

export interface UsageData {
/** Provider the report concerns, e.g. `did:web:storacha.network` */
provider: ProviderDID
Expand Down Expand Up @@ -285,6 +275,18 @@ export type RateLimitListFailure = Ucanto.Failure
// Space
export type Space = InferInvokedCapability<typeof SpaceCaps.space>
export type SpaceInfo = InferInvokedCapability<typeof SpaceCaps.info>
export type SpaceContentServe = InferInvokedCapability<
typeof SpaceCaps.contentServe
>
export type EgressRecord = InferInvokedCapability<typeof SpaceCaps.egressRecord>
export type EgressRecordSuccess = {
space: SpaceDID
resource: UnknownLink
bytes: number
servedAt: ISO8601Date
cause: UnknownLink
}
export type EgressRecordFailure = ConsumerNotFound | Ucanto.Failure

// filecoin
export interface DealMetadata {
Expand Down Expand Up @@ -901,6 +903,8 @@ export type ServiceAbilityArray = [
ProviderAdd['can'],
Space['can'],
SpaceInfo['can'],
SpaceContentServe['can'],
EgressRecord['can'],
Upload['can'],
UploadAdd['can'],
UploadGet['can'],
Expand Down
31 changes: 28 additions & 3 deletions packages/cli/space.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as W3Space from '@storacha/client/space'
import * as W3Account from '@storacha/client/account'
import * as UcantoClient from '@ucanto/client'
import { HTTP } from '@ucanto/transport'
import * as CAR from '@ucanto/transport/car'
import { getClient } from './lib.js'
import process from 'node:process'
import * as DIDMailto from '@storacha/did-mailto'
Expand All @@ -17,15 +20,36 @@ import * as Result from '@storacha/client/result'
* @property {false} [caution]
* @property {DIDMailto.EmailAddress|false} [customer]
* @property {string|false} [account]
* @property {Array<{id: import('@ucanto/interface').DID, serviceEndpoint: string}>} [authorizeGatewayServices] - The DID Key or DID Web and URL of the Gateway to authorize to serve content from the created space.
* @property {boolean} [skipGatewayAuthorization] - Whether to skip the Gateway authorization. It means that the content of the space will not be served by any Gateway.
*
* @param {string|undefined} name
* @param {CreateOptions} options
*/
export const create = async (name, options) => {
const client = await getClient()
const spaces = client.spaces()

const space = await client.createSpace(await chooseName(name ?? '', spaces))

let space
if (options.skipGatewayAuthorization === true) {
space = await client.createSpace(await chooseName(name ?? '', spaces), {
skipGatewayAuthorization: true,
})
} else {
const gateways = options.authorizeGatewayServices ?? []
const connections = gateways.map(({ id, serviceEndpoint }) =>
UcantoClient.connect({
id: {
did: () => id,
},
codec: CAR.outbound,
channel: HTTP.open({ url: new URL(serviceEndpoint) }),
})
)
space = await client.createSpace(await chooseName(name ?? '', spaces), {
authorizeGatewayServices: connections,
})
}

// Unless use opted-out from paper key recovery, we go through the flow
if (options.recovery !== false) {
Expand Down Expand Up @@ -382,7 +406,8 @@ export const selectAccount = async (client) => {

/**
* @param {import('@storacha/client').Client} client
*/
*/import { gateway } from '../capabilities/test/helpers/fixtures';

export const setupAccount = async (client) => {
const email = await input({
message: `📧 Please enter an email address to setup an account`,
Expand Down
7 changes: 5 additions & 2 deletions packages/filecoin-api/src/aggregator/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,11 @@ export const handleAggregateInsertToPieceAcceptQueue = async (
// TODO: Batch per a maximum to queue
const results = await map(
pieces,
/** @returns {Promise<import('@ucanto/interface').Result<import('@ucanto/interface').Unit, RangeError|import('../types.js').QueueAddError>>} */
async (piece) => {
/**
* @param piece
* @returns {Promise<import('@ucanto/interface').Result<import('@ucanto/interface').Unit, RangeError|import('../types.js').QueueAddError>>}
*/
async piece => {
const inclusionProof = aggregateBuilder.resolveProof(piece.link)
if (inclusionProof.error) return inclusionProof

Expand Down
2 changes: 2 additions & 0 deletions packages/upload-api/src/access/claim.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ export const provide = (ctx) =>

/**
* Checks if the given Principal is an Account.
*
* @param {API.Principal} principal
* @returns {principal is API.Principal<API.DID<'mailto'>>}
*/
const isAccount = (principal) => principal.did().startsWith('did:mailto:')

/**
* Returns true when the delegation has a `ucan:*` capability.
*
* @param {API.Delegation} delegation
* @returns boolean
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/upload-api/src/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,8 @@ export const execute = async (agent, input) => {
* a receipt it will return receipt without running invocation.
*
* @template {Record<string, any>} S
* @param {Types.Invocation} invocation
* @param {Agent<S>} agent
* @param {Types.Invocation} invocation
*/
export const run = async (agent, invocation) => {
const cached = await agent.context.agentStore.receipts.get(invocation.link())
Expand Down
2 changes: 2 additions & 0 deletions packages/upload-api/test/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,13 @@ export const mallory = ed25519.parse(
'MgCYtH0AvYxiQwBG6+ZXcwlXywq9tI50G2mCAUJbwrrahkO0B0elFYkl3Ulf3Q3A/EvcVY0utb4etiSE8e6pi4H0FEmU='
)

/** did:key:z6MkrZ1r5XBFZjBU34qyD8fueMbMRkKw17BZaq2ivKFjnz2z */
export const w3Signer = ed25519.parse(
'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8='
)
export const w3 = w3Signer.withDID('did:web:test.web3.storage')

/** did:key:z6MkuKJgV8DKxiAF5oaUcT8ckg8kZUoBe6yavSLnHn5ZgyAP */
export const gatewaySigner = ed25519.parse(
'MgCaNpGXCEX0+BxxE4SjSStrxU9Ru/Im+HGNQ/JJx3lDoI+0B3NWjWW3G8OzjbazZjanjM3kgfcZbvpyxv20jHtmcTtg='
)
Expand Down
118 changes: 109 additions & 9 deletions packages/w3up-client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
Receipt,
} from '@storacha/upload-client'
import {
Access as AccessCapabilities,
SpaceBlob as BlobCapabilities,
SpaceIndex as IndexCapabilities,
Upload as UploadCapabilities,
Filecoin as FilecoinCapabilities,
Space as SpaceCapabilities,
} from '@storacha/capabilities'
import * as DIDMailto from '@storacha/did-mailto'
import { Base } from './base.js'
Expand Down Expand Up @@ -243,19 +245,27 @@ export class Client extends Base {
}

/**
* Create a new space with a given name.
* Creates a new space with a given name.
* If an account is not provided, the space is created without any delegation and is not saved, hence it is a temporary space.
* When an account is provided in the options argument, then it creates a delegated recovery account
* by provisioning the space, saving it and then delegating access to the recovery account.
* In addition, it authorizes the listed Gateway Services to serve content from the created space.
* It is done by delegating the `space/content/serve/*` capability to the Gateway Service.
* User can skip the Gateway authorization by setting the `skipGatewayAuthorization` option to `true`.
*
* @typedef {object} CreateOptions
* @property {Account.Account} [account]
* @typedef {import('./types.js').ConnectionView<import('./types.js').ContentServeService>} ConnectionView
*
* @param {string} name
* @param {CreateOptions} options
* @typedef {object} SpaceCreateOptions
* @property {Account.Account} [account] - The account configured as the recovery account for the space.
* @property {Array<ConnectionView>} [authorizeGatewayServices] - The DID Key or DID Web of the Gateway to authorize to serve content from the created space.
* @property {boolean} [skipGatewayAuthorization] - Whether to skip the Gateway authorization. It means that the content of the space will not be served by any Gateway.
*
* @param {string} name - The name of the space to create.
* @param {SpaceCreateOptions} options - Options for the space creation.
* @returns {Promise<import("./space.js").OwnedSpace>} The created space owned by the agent.
*/
async createSpace(name, options = {}) {
// Save the space to authorize the client to use the space
const space = await this._agent.createSpace(name)

const account = options.account
Expand All @@ -276,18 +286,35 @@ export class Client extends Base {
const recovery = await space.createRecovery(account.did())

// Delegate space access to the recovery
const result = await this.capability.access.delegate({
const delegationResult = await this.capability.access.delegate({
space: space.did(),
delegations: [recovery],
})

if (result.error) {
if (delegationResult.error) {
throw new Error(
`failed to authorize recovery account: ${delegationResult.error.message}`,
{ cause: delegationResult.error }
)
}
}

// Authorize the listed Gateway Services to serve content from the created space
if (options.skipGatewayAuthorization !== true) {
if (
!options.authorizeGatewayServices ||
options.authorizeGatewayServices.length === 0
) {
throw new Error(
`failed to authorize recovery account: ${result.error.message}`,
{ cause: result.error }
'failed to authorize Gateway Services: missing <authorizeGatewayServices> option'
)
}

for (const serviceConnection of options.authorizeGatewayServices) {
await authorizeContentServe(this, space, serviceConnection)
}
}

return space
}

Expand Down Expand Up @@ -507,3 +534,76 @@ export class Client extends Base {
await this.capability.upload.remove(contentCID)
}
}

/**
* Authorizes an audience to serve content from the provided space and record egress events.
* It also publishes the delegation to the content serve service.
* Delegates the following capabilities to the audience:
* - `space/content/serve/*`
*
* @param {Client} client - The w3up client instance.
* @param {import('./types.js').OwnedSpace} space - The space to authorize the audience for.
* @param {import('./types.js').ConnectionView<import('./types.js').ContentServeService>} connection - The connection to the Content Serve Service that will handle, validate, and store the access/delegate UCAN invocation.
* @param {object} [options] - Options for the content serve authorization invocation.
* @param {`did:${string}:${string}`} [options.audience] - The Web DID of the audience (gateway or peer) to authorize.
* @param {number} [options.expiration] - The time at which the delegation expires in seconds from unix epoch.
*/
export const authorizeContentServe = async (
client,
space,
connection,
options = {}
) => {
const currentSpace = client.currentSpace()
try {
// Set the current space to the space we are authorizing the gateway for, otherwise the delegation will fail
await client.setCurrentSpace(space.did())

/** @type {import('@ucanto/client').Principal<`did:${string}:${string}`>} */
const audience = {
did: () => options.audience ?? connection.id.did(),
}

// Grant the audience the ability to serve content from the space, it includes existing proofs automatically
const delegation = await client.createDelegation(
audience,
[SpaceCapabilities.contentServe.can],
{
expiration: options.expiration ?? Infinity,
}
)

// Publish the delegation to the content serve service
const accessProofs = client.proofs([
{ can: AccessCapabilities.access.can, with: space.did() },
])
const verificationResult = await AccessCapabilities.delegate
.invoke({
issuer: client.agent.issuer,
audience,
with: space.did(),
proofs: [...accessProofs, delegation],
nb: {
delegations: {
[delegation.cid.toString()]: delegation.cid,
},
},
})
.execute(connection)

/* c8 ignore next 8 - can't mock this error */
if (verificationResult.out.error) {
throw new Error(
`failed to publish delegation for audience ${options.audience}: ${verificationResult.out.error.message}`,
{
cause: verificationResult.out.error,
}
)
}
return { ok: { ...verificationResult.out.ok, delegation } }
} finally {
if (currentSpace) {
await client.setCurrentSpace(currentSpace.did())
}
}
}
1 change: 1 addition & 0 deletions packages/w3up-client/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Client } from './client.js'
export * as Result from './result.js'
export * as Account from './account.js'
export * from './ability.js'
export { authorizeContentServe } from './client.js'

/**
* Create a new w3up client.
Expand Down
13 changes: 13 additions & 0 deletions packages/w3up-client/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { type Driver } from '@storacha/access/drivers/types'
import {
AccessDelegate,
AccessDelegateFailure,
AccessDelegateSuccess,
type Service as AccessService,
type AgentDataExport,
} from '@storacha/access/types'
Expand All @@ -11,6 +14,7 @@ import type {
Ability,
Resource,
Unit,
ServiceMethod,
} from '@ucanto/interface'
import { type Client } from './client.js'
import { StorefrontService } from '@storacha/filecoin-client/storefront'
Expand All @@ -36,6 +40,15 @@ export interface ServiceConf {
filecoin: ConnectionView<StorefrontService>
}

export interface ContentServeService {
access: {
delegate: ServiceMethod<
AccessDelegate,
AccessDelegateSuccess,
AccessDelegateFailure
>
}
}
export interface ClientFactoryOptions {
/**
* A storage driver that persists exported agent data.
Expand Down
Loading

0 comments on commit 0d4513d

Please sign in to comment.