Skip to content

Commit

Permalink
feat!: content serve authorization (#1590)
Browse files Browse the repository at this point in the history
### Context
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.

### Main Changes
- **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).

### Related Issues
- storacha/project-tracking#158
- storacha/project-tracking#160
  • Loading branch information
fforbeck authored Dec 4, 2024
1 parent c3f62e3 commit 8b553a5
Show file tree
Hide file tree
Showing 22 changed files with 541 additions and 73 deletions.
24 changes: 14 additions & 10 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,16 +131,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:web3.storage` */
provider: ProviderDID
Expand Down Expand Up @@ -284,6 +274,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 @@ -895,6 +897,8 @@ export type ServiceAbilityArray = [
ProviderAdd['can'],
Space['can'],
SpaceInfo['can'],
SpaceContentServe['can'],
EgressRecord['can'],
Upload['can'],
UploadAdd['can'],
UploadGet['can'],
Expand Down
5 changes: 4 additions & 1 deletion packages/filecoin-api/src/aggregator/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,10 @@ 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>>} */
/**
* @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 @@ -147,8 +147,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
120 changes: 110 additions & 10 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 '@web3-storage/upload-client'
import {
Access as AccessCapabilities,
Blob as BlobCapabilities,
Index as IndexCapabilities,
Upload as UploadCapabilities,
Filecoin as FilecoinCapabilities,
Space as SpaceCapabilities,
} from '@web3-storage/capabilities'
import * as DIDMailto from '@web3-storage/did-mailto'
import { Base } from './base.js'
Expand Down Expand Up @@ -246,19 +248,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 = {}) {
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 @@ -279,18 +289,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 @@ -525,3 +552,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 '@web3-storage/access/drivers/types'
import {
AccessDelegate,
AccessDelegateFailure,
AccessDelegateSuccess,
type Service as AccessService,
type AgentDataExport,
} from '@web3-storage/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 '@web3-storage/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
22 changes: 16 additions & 6 deletions packages/w3up-client/test/account.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,9 @@ export const testAccount = Test.withContext({
assert,
{ client, mail, grantAccess }
) => {
const space = await client.createSpace('test')
const space = await client.createSpace('test', {
skipGatewayAuthorization: true,
})
const mnemonic = space.toMnemonic()
const { signer } = await Space.fromMnemonic(mnemonic, { name: 'import' })
assert.deepEqual(
Expand Down Expand Up @@ -147,7 +149,9 @@ export const testAccount = Test.withContext({

'multi device workflow': async (asserts, { connect, mail, grantAccess }) => {
const laptop = await connect()
const space = await laptop.createSpace('main')
const space = await laptop.createSpace('main', {
skipGatewayAuthorization: true,
})

// want to provision space ?
const email = '[email protected]'
Expand Down Expand Up @@ -183,7 +187,9 @@ export const testAccount = Test.withContext({
asserts.deepEqual(result.did, space.did())
},
'setup recovery': async (assert, { client, mail, grantAccess }) => {
const space = await client.createSpace('test')
const space = await client.createSpace('test', {
skipGatewayAuthorization: true,
})

const email = '[email protected]'
const login = Account.login(client, email)
Expand Down Expand Up @@ -280,7 +286,9 @@ export const testAccount = Test.withContext({
assert,
{ client, mail, grantAccess }
) => {
const space = await client.createSpace('test')
const space = await client.createSpace('test', {
skipGatewayAuthorization: true,
})

const email = '[email protected]'
const login = Account.login(client, email)
Expand All @@ -299,8 +307,10 @@ export const testAccount = Test.withContext({
assert.equal(typeof subs.results[0].subscription, 'string')
},

'space.save': async (assert, { client, mail, grantAccess }) => {
const space = await client.createSpace('test')
'space.save': async (assert, { client }) => {
const space = await client.createSpace('test', {
skipGatewayAuthorization: true,
})
assert.deepEqual(client.spaces(), [])

const result = await space.save()
Expand Down
7 changes: 5 additions & 2 deletions packages/w3up-client/test/capability/access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const AccessClient = Test.withContext({
},
'should delegate and then claim': async (
assert,
{ connection, provisionsStorage }
{ id: w3, connection, provisionsStorage }
) => {
const alice = new Client(await AgentData.create(), {
// @ts-ignore
Expand All @@ -29,7 +29,10 @@ export const AccessClient = Test.withContext({
upload: connection,
},
})
const space = await alice.createSpace('upload-test')

const space = await alice.createSpace('upload-test', {
skipGatewayAuthorization: true,
})
const auth = await space.createAuthorization(alice)
await alice.addSpace(auth)
await alice.setCurrentSpace(space.did())
Expand Down
Loading

0 comments on commit 8b553a5

Please sign in to comment.