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: add access/delegate capability parser exported from @web3-storage/capabilities #420

Merged
merged 17 commits into from
Feb 7, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion packages/capabilities/src/access.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*
* @module
*/
import { capability, URI, DID } from '@ucanto/validator'
import { capability, URI, DID, Schema, Failure } from '@ucanto/validator'
// @ts-ignore
// eslint-disable-next-line no-unused-vars
import * as Types from '@ucanto/interface'
Expand Down Expand Up @@ -108,3 +108,86 @@ export const claim = base.derive({
}),
derives: equalWith,
})

// https://github.com/web3-storage/specs/blob/main/w3-access.md#accessdelegate
export const delegate = base.derive({
to: capability({
can: 'access/delegate',
/**
* Field MUST be a space DID with a storage provider. Delegation will be stored just like any other DAG stored using store/add capability.
*
* @see https://github.com/web3-storage/specs/blob/main/w3-access.md#delegate-with
*/
with: DID.match({ method: 'key' }),
nb: {
// keys SHOULD be CIDs, but we won't require it in the schema
delegations: Schema.dictionary({
value: Schema.Link.match(),
}),
},
derives: (claim, proof) => {
return (
fail(equalWith(claim, proof)) ||
fail(subsetsNbDelegations(claim, proof)) ||
true
)
},
}),
derives: (claim, proof) => {
// no need to check claim.nb.delegations is subset of proof
// because the proofs types here never include constraints on the nb.delegations set
return fail(equalWith(claim, proof)) || true
},
})

/**
* Parsed Capability for access/delegate
*
* @template {Types.Ability} A
* @template {Types.URI} R
* @typedef {Types.ParsedCapability<A, R, { delegations?: Types.Failure | Schema.Dictionary<string, Types.Link<unknown, number, number, 0 | 1>> }>} ParsedAccessDelegate
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I use this below on lines 159, 160. @Gozala lmk if there is some better way of inferring this type. It's possible I can't just infer it from delegate because it's also used in the definition of delegate (derives) so there is a circularity?

Copy link
Contributor

Choose a reason for hiding this comment

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

delegations should not be Failure, ucanto will not create parsed capability like that. It will either parse successfully or just fail, it will never partially fail.

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* @typedef {Types.ParsedCapability<A, R, { delegations?: Types.Failure | Schema.Dictionary<string, Types.Link<unknown, number, number, 0 | 1>> }>} ParsedAccessDelegate
* @typedef {Types.ParsedCapability<A, R, { delegations?: Schema.Dictionary<string, Types.Link<unknown, number, number, 0 | 1>> }>} ParsedAccessDelegate

Does this not work ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@Gozala and I met together, and realized I needed to add this type assertion

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I proposed this in ucanto to make the type assertion unnecessary storacha/ucanto#215

*/

/**
* returns whether the claimed ucan is proves by the proof ucan.
* both are access/delegate, or at least have same semantics for `nb.delegations`, which is a set of delegations.
* checks that the claimed delegation set is equal to or less than the proven delegation set.
* usable with {import('@ucanto/interface').Derives}.
*
* @template {Types.Ability} A
* @template {Types.URI} R
* @param {ParsedAccessDelegate<A,R>} claim
* @param {ParsedAccessDelegate<A,R>} proof
*/
function subsetsNbDelegations(claim, proof) {
/** @param {ParsedAccessDelegate<A,R>} ucan */
const nbDelegationsCids = (ucan) =>
new Set(Object.values(ucan.nb.delegations || {}).map(String))
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: can you hoist this function, to the top level since it does not enclose anything

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hoisted in d03e081

const missingProofs = setDifference(
new Set(nbDelegationsCids(claim)),
Copy link
Contributor

Choose a reason for hiding this comment

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

You create a set and then pass it into a set constructor, which seems unnecessary

Copy link
Contributor Author

Choose a reason for hiding this comment

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

new Set(nbDelegationsCids(proof))
)
if (missingProofs.size > 0) {
return new Failure(
`Can not derive ${claim.can}. Missing proofs for delegations: ${[
...missingProofs,
].join(', ')}`
)
}
return true
}

/**
* @template S
* @param {Set<S>} minuend - set to subtract from
* @param {Set<S>} subtrahend - subtracted from minuend
*/
function setDifference(minuend, subtrahend) {
const difference = new Set()
for (const e of minuend) {
if (!subtrahend.has(e)) {
difference.add(e)
}
}
return difference
}
131 changes: 130 additions & 1 deletion packages/capabilities/test/capabilities/access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Verifier } from '@ucanto/principal/ed25519'
import * as Access from '../../src/access.js'
import { alice, bob, service, mallory } from '../helpers/fixtures.js'
import * as Ucanto from '@ucanto/interface'
import { delegate, invoke } from '@ucanto/core'
import { delegate, invoke, parseLink } from '@ucanto/core'

describe('access capabilities', function () {
it('should self issue', async function () {
Expand Down Expand Up @@ -371,3 +371,132 @@ describe('access capabilities', function () {
})
})
})

describe('access/delegate', () => {
it('can create valid delegations and authorize them', async () => {
const issuer = alice
const audience = service.withDID('did:web:web3.storage')
const bobCanStoreAllWithAlice = await delegate({
issuer: alice,
audience: bob,
capabilities: [{ can: 'store/*', with: alice.did() }],
})
// @todo - add example of delegating alice -> bob
const examples = [
// uncommon to have empty delegation set, but it is valid afaict
Access.delegate
.invoke({
issuer,
audience,
with: issuer.did(),
nb: {
delegations: {},
},
})
.delegate(),
// https://github.com/web3-storage/specs/blob/7e662a2d9ada4e3fc22a7a68f84871bff0a5380c/w3-access.md?plain=1#L58
// with several different, but all valid, property names to use in the `nb.delegations` dict
.../** @type {const} */ ([
// correct cid
[bobCanStoreAllWithAlice.cid.toString(), bobCanStoreAllWithAlice.cid],
// not a cid at all
['thisIsNotACid', bobCanStoreAllWithAlice.cid],
Copy link
Contributor

Choose a reason for hiding this comment

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

🤔 I'm starting to wonder if the whole dict is a good idea here, but then if we chose list it also may not be ordered.

// cid that does not correspond to value
[parseLink('bafkqaaa').toString(), bobCanStoreAllWithAlice.cid],
]).map(([delegationDictKey, delegationLink]) =>
Access.delegate
.invoke({
issuer,
audience,
with: issuer.did(),
nb: {
delegations: {
[delegationDictKey]: delegationLink,
},
},
proofs: [bobCanStoreAllWithAlice],
})
.delegate()
),
]
for (const example of examples) {
const invocation = await example
const result = await access(invocation, {
capability: Access.delegate,
principal: Verifier,
authority: audience,
})
assert.ok(
result.error !== true,
'result of access(invocation) is not an error'
)
assert.deepEqual(
result.audience.did(),
audience.did(),
'result audience did is expected value'
)
assert.equal(
result.capability.can,
'access/delegate',
'result capability.can is access/delegate'
)
assert.deepEqual(
result.capability.nb,
invocation.capabilities[0].nb,
'result has expected nb'
)
}
})
// @todo test can derive from access/* to access/delegate
it('can only delegate a subset of nb.delegations', async () => {
const audience = service
/** @param {string} methodName */
const createTestDelegation = (methodName) =>
delegate({
issuer: alice,
audience: bob,
capabilities: [{ can: `test/${methodName}`, with: alice.did() }],
})
/** @param {number} length */
const createTestDelegations = (length) =>
Promise.all(
Array.from({ length }).map((_, i) => createTestDelegation(i.toString()))
)
const allTestDelegations = await createTestDelegations(2)
const [, ...someTestDelegations] = allTestDelegations
const bobCanDelegateSomeWithAlice = await Access.delegate.delegate({
issuer: alice,
audience: bob,
with: alice.did(),
nb: {
delegations: toDelegationsDict(someTestDelegations),
},
})
const invocation = await Access.delegate
.invoke({
issuer: bob,
audience,
with: alice.did(),
nb: {
delegations: toDelegationsDict(allTestDelegations),
},
proofs: [bobCanDelegateSomeWithAlice],
})
.delegate()
const result = await access(invocation, {
capability: Access.delegate,
principal: Verifier,
authority: audience,
})
assert.ok(result.error === true, 'result of access(invocation) is an error')
})
})

/**
* Given array of delegations, return a valid value for access/delegate nb.delegations
*
* @param {Array<Ucanto.Delegation>} delegations
*/
function toDelegationsDict(delegations) {
return Object.fromEntries(delegations.map((d) => [d.cid.toString(), d.cid]))
}