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

feat!: content serve authorization #1590

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
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.
*
Copy link
Member Author

@fforbeck fforbeck Dec 2, 2024

Choose a reason for hiding this comment

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

fixed lint issue

* @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.
*
Copy link
Member Author

@fforbeck fforbeck Dec 2, 2024

Choose a reason for hiding this comment

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

fixed lint issue

* @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
Copy link
Member Author

Choose a reason for hiding this comment

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

fixed lint issue

*/
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 = 'alice@web.mail'
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 = 'alice@web.mail'
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 = 'alice@web.mail'
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
Loading