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

Merged
merged 11 commits into from
Dec 4, 2024
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
113 changes: 108 additions & 5 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 @@ -250,15 +252,23 @@ export class Client extends Base {
* 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 Content Serve Services to serve content from the created space.
* It is done by delegating the `space/content/serve/*` capability to the Content Serve Service.
* User can skip the Content Serve authorization by setting the `skipContentServeAuthorization` option to `true`.
*
* @typedef {object} CreateOptions
* @property {Account.Account} [account]
* @typedef {object} SpaceCreateOptions
* @property {boolean} [skipContentServeAuthorization] - Whether to skip the Content Serve authorization. It means that the content of the space will not be served by any Content Serve Service.
* @property {`did:${string}:${string}`[]} [authorizeContentServeServices] - The DID Key or DID Web of the Content Serve Service to authorize to serve content from the created space.
* @property {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.
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
* @property {Account.Account} [account] - The account configured as the recovery account for the space.
* @property {string} [name] - The name of the space to create.
*
* @param {string} name
* @param {CreateOptions} options
* @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 Down Expand Up @@ -291,9 +301,102 @@ export class Client extends Base {
)
}
}

// Authorize the listed Content Serve Services to serve content from the created space
if (options.skipContentServeAuthorization !== true) {
if (
!options.authorizeContentServeServices ||
options.authorizeContentServeServices.length === 0
) {
throw new Error(
'failed to authorize Content Serve Services: missing <authorizeContentServeServices> option'
)
}

if (!options.connection) {
throw new Error(
'failed to authorize Content Serve Services: missing <connection> option'
)
}

for (const service of options.authorizeContentServeServices) {
await this.authorizeContentServe(space, {
audience: service,
connection: options.connection,
})
}
}

return space
}

/**
* 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 {import('./types.js').OwnedSpace} space - The space to authorize the audience for.
* @param {object} options - Options for the authorization.
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
* @param {`did:${string}:${string}`} options.audience - The Web DID of the audience (gateway or peer) to authorize.
* @param {import('./types.js').ConnectionView<import('./types.js').ContentServeService>} options.connection - The connection to the Content Serve Service that will handle, validate, and store the access/delegate UCAN invocation.
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
* @param {number} [options.expiration] - The time at which the delegation expires in seconds from unix epoch.
*/
async authorizeContentServe(space, options) {
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
const currentSpace = this.currentSpace()
try {
// Set the current space to the space we are authorizing the gateway for, otherwise the delegation will fail
await this.setCurrentSpace(space.did())

/** @type {import('@ucanto/client').Principal<`did:${string}:${string}`>} */
const audience = {
did: () => options.audience,
fforbeck marked this conversation as resolved.
Show resolved Hide resolved
}

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

// Publish the delegation to the content serve service
const accessProofs = this.proofs([
{ can: AccessCapabilities.access.can, with: space.did() },
])
const verificationResult = await AccessCapabilities.delegate
.invoke({
issuer: this._agent.issuer,
audience,
with: space.did(),
proofs: [...accessProofs, delegation],
nb: {
delegations: {
[delegation.cid.toString()]: delegation.cid,
},
},
})
.execute(options.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 this.setCurrentSpace(currentSpace.did())
}
}
}

/**
* Share an existing space with another Storacha account via email address delegation.
* Delegates access to the space to the specified email account with the following permissions:
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', {
skipContentServeAuthorization: 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', {
skipContentServeAuthorization: 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', {
skipContentServeAuthorization: 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', {
skipContentServeAuthorization: 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', {
skipContentServeAuthorization: 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', {
skipContentServeAuthorization: true,
})
const auth = await space.createAuthorization(alice)
await alice.addSpace(auth)
await alice.setCurrentSpace(space.did())
Expand Down
16 changes: 12 additions & 4 deletions packages/w3up-client/test/capability/blob.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ export const BlobClient = Test.withContext({
receiptsEndpoint: new URL(receiptsEndpoint),
})

const space = await alice.createSpace('test')
const space = await alice.createSpace('test', {
skipContentServeAuthorization: true,
})
const auth = await space.createAuthorization(alice)
await alice.addSpace(auth)
await alice.setCurrentSpace(space.did())
Expand Down Expand Up @@ -56,7 +58,9 @@ export const BlobClient = Test.withContext({
receiptsEndpoint: new URL(receiptsEndpoint),
})

const space = await alice.createSpace('test')
const space = await alice.createSpace('test', {
skipContentServeAuthorization: true,
})
const auth = await space.createAuthorization(alice)
await alice.addSpace(auth)
await alice.setCurrentSpace(space.did())
Expand Down Expand Up @@ -94,7 +98,9 @@ export const BlobClient = Test.withContext({
receiptsEndpoint: new URL(receiptsEndpoint),
})

const space = await alice.createSpace('test')
const space = await alice.createSpace('test', {
skipContentServeAuthorization: true,
})
const auth = await space.createAuthorization(alice)
await alice.addSpace(auth)
await alice.setCurrentSpace(space.did())
Expand Down Expand Up @@ -126,7 +132,9 @@ export const BlobClient = Test.withContext({
receiptsEndpoint: new URL(receiptsEndpoint),
})

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