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: define access/claim in @web3-storage/capabilities #409

Merged
merged 9 commits into from
Feb 1, 2023
3 changes: 2 additions & 1 deletion packages/capabilities/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@
"rules": {
"unicorn/prefer-number-properties": "off",
"unicorn/prefer-export-from": "off",
"unicorn/no-array-reduce": "off"
"unicorn/no-array-reduce": "off",
"jsdoc/no-undefined-types": "error"
},
"env": {
"mocha": true
Expand Down
10 changes: 9 additions & 1 deletion packages/capabilities/src/access.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export const access = top.derive({
to: capability({
can: 'access/*',
with: URI.match({ protocol: 'did:' }),
derives: equalWith,
}),
derives: equalWith,
})
Expand Down Expand Up @@ -100,3 +99,12 @@ export const session = capability({
key: DID.match({ method: 'key' }),
},
})

export const claim = base.derive({
gobengo marked this conversation as resolved.
Show resolved Hide resolved
to: capability({
can: 'access/claim',
with: DID.match({ method: 'key' }).or(DID.match({ method: 'mailto' })),
derives: equalWith,
}),
derives: equalWith,
gobengo marked this conversation as resolved.
Show resolved Hide resolved
})
148 changes: 148 additions & 0 deletions packages/capabilities/test/capabilities/access.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { access } from '@ucanto/validator'
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'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This would have required a comment to disable tsc no-unused-vars, but it no longer does because of adding jsdoc/no-undefined-types rule

related to: https://github.com/web3-storage/w3protocol/pull/392/files#r1090596726

import { delegate, invoke } from '@ucanto/core'

describe('access capabilities', function () {
it('should self issue', async function () {
Expand Down Expand Up @@ -222,4 +224,150 @@ describe('access capabilities', function () {
})
}, /Expected a did:mailto: but got "did:NOT_MAILTO:web3.storage:test" instead/)
})

describe('access/claim', () => {
// ensure we can use the capability to produce the invocations from the spec at https://github.com/web3-storage/specs/blob/576b988fb7cfa60049611963179277c420605842/w3-access.md
it('can create/access delegations from spec', async () => {
const audience = service.withDID('did:web:web3.storage')
/**
* @type {Array<(arg: { issuer: Ucanto.Signer<Ucanto.DID<'key'>>}) => Ucanto.IssuedInvocation<Ucanto.InferInvokedCapability<typeof Access.claim>>>}
*/
const examples = [
// https://github.com/web3-storage/specs/blob/576b988fb7cfa60049611963179277c420605842/w3-access.md#accessclaim
({ issuer }) => {
return Access.claim.invoke({
issuer,
audience,
with: issuer.did(),
})
},
]
for (const example of examples) {
const invocation = await example({ issuer: bob }).delegate()
const result = await access(invocation, {
capability: Access.claim,
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/claim',
'result capability.can is access/claim'
)
assert.deepEqual(result.capability.nb, {}, 'result has empty nb')
}
})
it('can be derived', async () => {
/** @type {Array<Ucanto.Ability>} */
const cansThatShouldDeriveAccessClaim = ['*', 'access/*']
for (const can of cansThatShouldDeriveAccessClaim) {
const invocation = await invoke({
issuer: alice,
audience: service,
capability: {
can: 'access/claim',
with: bob.did(),
},
proofs: [
await delegate({
issuer: bob,
audience: alice,
capabilities: [
{
can,
with: bob.did(),
},
],
}),
],
}).delegate()
const result = await access(invocation, {
capability: Access.claim,
principal: Verifier,
authority: service,
})
assert.ok(
result.error !== true,
'result of access(invocation) is not an error'
)
}
})
it('cannot invoke when .with uses unexpected did method', async () => {
const issuer = bob.withDID('did:foo:bar')
assert.throws(
() =>
Access.claim.invoke({
issuer,
audience: service,
// @ts-ignore - expected complaint from compiler. We want to make sure there is an equivalent error at runtime
with: issuer.did(),
}),
`Invalid 'with'`
)
})
it('does not authorize invocations whose .with uses unexpected did methods', async () => {
const issuer = bob
const audience = service
const invocation = await delegate({
issuer,
audience,
capabilities: [
{
can: 'access/claim',
with: issuer.withDID('did:foo:bar').did(),
},
],
})
const result = await access(
// @ts-ignore - expected complaint from compiler. We want to make sure there is an equivalent error at runtime
invocation,
{
capability: Access.claim,
principal: Verifier,
authority: audience,
}
)
assert.ok(result.error, 'result of access(invocation) is an error')
assert.deepEqual(result.name, 'Unauthorized')
assert.ok(
result.delegationErrors.find((e) =>
e.message.includes('but got "did:foo:bar" instead')
),
'a result.delegationErrors message mentions invalid with value'
)
})
it('does not authorize invocations whose .with is not an issuer in proofs', async () => {
const issuer = bob
const audience = service
const invocation = await Access.claim
.invoke({
issuer,
audience,
// note: this did is not same as issuer.did() so issuer has no proof that they can use this resource
with: alice.did(),
})
.delegate()
const result = await access(invocation, {
capability: Access.claim,
principal: Verifier,
authority: audience,
})
assert.ok(result.error, 'result of access(invocation) is an error')
assert.deepEqual(result.name, 'Unauthorized')
assert.ok(
result.failedProofs.find((e) => {
return /Capability (.+) is not authorized/.test(e.message)
})
)
})
})
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 not sure what this is testing exactly that no error is thrown ? I would suggest covering more surface here, specifically to ensure:

  1. That wrong claims fail validation when with is mismatched.
  2. Since you only support did:key and did:mailto validate that those resources are ok, but other DIDs are not.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Would you say this is a valid test for what you mean by #1?

For #2 I added:

})