-
Notifications
You must be signed in to change notification settings - Fork 23
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
Changes from 8 commits
7ecd857
e1823f4
62770fa
cbf3f89
235e46e
8922c0b
2579081
1e256f1
4f1f293
a6e52bf
d03e081
edeee6b
5a8ed04
6be0322
fce9027
57f51ea
f683c33
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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' | ||
|
@@ -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 | ||
*/ | ||
|
||
/** | ||
* 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)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hoisted in d03e081 |
||
const missingProofs = setDifference( | ||
new Set(nbDelegationsCids(claim)), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 () { | ||
|
@@ -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], | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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])) | ||
} |
There was a problem hiding this comment.
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 ofdelegate
(derives) so there is a circularity?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
delegations
should not beFailure
, ucanto will not create parsed capability like that. It will either parse successfully or just fail, it will never partially fail.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this not work ?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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