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

[LC-339] Boost Parents/Children and Permissions #546

Merged
merged 10 commits into from
Oct 30, 2024

Conversation

TaylorBeeston
Copy link
Collaborator

@TaylorBeeston TaylorBeeston commented Oct 25, 2024

Overview

https://www.loom.com/share/06222d43e0514fcb8907e1332ea9957d

🎟 Relevant Jira Issues

[LC-339] Back-End: Stackable Boosts & Permissions

📚 What is the context and goal of this PR?

To allow for Scouts Troops, we need to add the ability for Boosts to have parents/children, and we
also then need a permissions overhaul to account for those new relationships

🥴 TL; RL:

Adds parent/child boosts, and updates permissions on boosts to be more granular

💡 Feature Breakdown (screenshots & videos encouraged!)

  • New node: Role

    • Has BoostPermissions properties:
      • canEdit: boolean
      • canIssue: boolean
      • canRevoke: boolean
      • canManagePermissions: boolean
      • canIssueChildren: string mongo query
      • canCreateChildren: string mongo query
      • canEditChildren: string mongo query
      • canRevokeChildren: string mongo query
      • canManageChildrenPermissions: string mongo query
      • canViewAnalytics: boolean
    • Specifies a set of permissions
  • New relationsip for Boosts: PARENT_OF

    • Specifies a parent/child relationship between two boosts
  • New relationsip between Boosts and Profiles: HAS_ROLE

    • Points to a role via roleId
    • Also optionally holds any permission overrides
  • New routes

    • createChildBoost: like create boost, but takes in a uri and creates a child under that boost
    • makeBoostParent: creates a parent/child relationship between two boosts
    • removeBoostParent: removes a parent/child relationship between two boosts
    • getBoostChildren: paginated endpoint for getting child boosts with an optional number of generations (i.e. grandchildren)
    • countBoostChildren: count endpoint for getting child boosts with an optional number of generations (i.e. grandchildren)
    • getBoostParents: paginated endpoint for getting parent boosts with an optional number of generations (i.e. grandparents)
    • countBoostParents: count endpoint for getting parent boosts with an optional number of generations (i.e. grandparents)
    • getBoostPermissions: gets the current user's BoostPermissions for a given boost
    • getOtherBoostPermissions: gets another user's BoostPermissions for a given boost
    • updateBoostPermissions: updates the current user's BoostPermissions for a given boost
    • updateOtherBoostPermissions: updates another user's BoostPermissions for a given boost

🛠 Important tradeoffs made:

There's like a whole bunch of tradeoffs all over the place! Some big ones are performance related.
Storing roles as a separate node does incur a perf overhead, but after doing some profiling, I found
that it is negliglible compared to the benefit we get from having it. There's also a performance tradeoff
with the way that we query for specific permissions. Right now, those are optimized toward a read pattern
where there are not usually a ton of parents/grandparents per boost (what happens is neo4j returns an object
for each boost/parent that includes the child boost's perms in every object, repeating the child boost's
perms for every parent! This can be defeated using COLLECT, but that incurs a perf cost even if there
are no parents, so we instead to prefer to take the hit only if there are lots of parents!). We are storing
child permissions as a MongoDB Query (using sift under the hood) string, which has a whole bucket of
implications/tradeoffs, and we also just go ahead and apply that query using javascript instead of in
neo4j, which could potentially have huge performance negatives if we ever add the ability to say view
all children/grandchildren boosts that match a given boosts canIssueChildren permission and there are
tons of them that don't match it

🔍 Types of Changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Chore (refactor, documentation update, etc)

💳 Does This Create Any New Technical Debt? ( If yes, please describe and add JIRA TODOs )

  • No
  • Yes

Testing

🔬 How Can Someone QA This?

So there's a lot of implied behavior going on, especially with permissions, and I wrote some pretty
extensive tests to account for that, but there's still some manual testing you can do to like check
all of that and play around with things!

First, spin up the network, easiest way now is to do this:

cd services/learn-card-network
docker-compose up --build

(You might have to remove your other docker containers via docker container rm)

Once that's up start the CLI:

pnpm exec nx start cli --skip-nx-cache

Then try out all of these guys!

let test = await initLearnCard({ seed: 'a', network: 'http://localhost:4000/trpc', cloud: { url: 'http://localhost:5000/trpc' } });
let test2 = await initLearnCard({ seed: 'b', network: 'http://localhost:4000/trpc', cloud: { url: 'http://localhost:5000/trpc' } });
let test3 = await initLearnCard({ seed: 'c', network: 'http://localhost:4000/trpc', cloud: { url: 'http://localhost:5000/trpc' } });

await test.invoke.createProfile({ profileId: 'nice' });
await test2.invoke.createProfile({ profileId: 'test2' });
await test3.invoke.createProfile({ profileId: 'test3' });

const testUnsignedBoost = {
    '@context': [
        'https://www.w3.org/2018/credentials/v1',
        'https://purl.imsglobal.org/spec/ob/v3p0/context.json',
        {
            type: '@type',
            xsd: 'https://www.w3.org/2001/XMLSchema#',
            lcn: 'https://docs.learncard.com/definitions#',
            BoostCredential: {
                '@id': 'lcn:boostCredential',
                '@context': {
                    boostId: { '@id': 'lcn:boostId', '@type': 'xsd:string' },
                    display: {
                        '@id': 'lcn:boostDisplay',
                        '@context': {
                            backgroundImage: {
                                '@id': 'lcn:boostBackgroundImage',
                                '@type': 'xsd:string',
                            },
                            backgroundColor: {
                                '@id': 'lcn:boostBackgroundColor',
                                '@type': 'xsd:string',
                            },
                        },
                    },
                    image: { '@id': 'lcn:boostImage', '@type': 'xsd:string' },
                    attachments: {
                        '@id': 'lcn:boostAttachments',
                        '@container': '@set',
                        '@context': {
                            type: { '@id': 'lcn:boostAttachmentType', '@type': 'xsd:string' },
                            title: { '@id': 'lcn:boostAttachmentTitle', '@type': 'xsd:string' },
                            url: { '@id': 'lcn:boostAttachmentUrl', '@type': 'xsd:string' },
                        },
                    },
                },
            },
        },
    ],
    type: ['VerifiableCredential', 'OpenBadgeCredential', 'BoostCredential'],
    issuer: 'did:web:localhost%3A3000:users:nice',
    issuanceDate: '2020-08-19T21:41:50Z',
    name: 'Example Boost',
    credentialSubject: {
        id: 'did:example:d23dd687a7dc6787646f2eb98d0',
        type: ['AchievementSubject'],
        achievement: {
            id: 'urn:uuid:123',
            type: ['Achievement'],
            achievementType: 'Influencer',
            name: 'Awesome Badge',
            description: 'Awesome People Earn Awesome Badge',
            image: '',
            criteria: { narrative: 'Earned by being awesome.' },
        },
    },
};

let uri = await test.invoke.createBoost(testUnsignedBoost, { status: 'DRAFT' });
let childUri = await test.invoke.createChildBoost(uri, testUnsignedBoost, { status: 'DRAFT' });
let grandChildUri = await test.invoke.createChildBoost(childUri, testUnsignedBoost, { status: 'DRAFT' });

await test.invoke.getBoostPermissions(uri);
await test.invoke.getBoostPermissions(uri, 'test2');
await test.invoke.getBoostPermissions(uri, 'test3');

await test.invoke.addBoostAdmin(uri, 'test2');
await test.invoke.getBoostPermissions(uri, 'test2');

// Admins can update boosts
await test.invoke.updateBoost(uri, { category: 'Nice' });
await test2.invoke.updateBoost(uri, { category: 'Nice!' });

// This specific permission can be turned off
await test.invoke.updateBoostPermissions(uri, { canEdit: false }, 'test2');
await test2.invoke.updateBoost(uri, { category: 'Aww' });
await test.invoke.getBoost(uri);

// Admins can issue boosts
await test.invoke.updateBoost(uri, { status: 'LIVE' });
await test.invoke.sendBoost('test3', uri);
await test2.invoke.sendBoost('test3', uri);

// This can also be turned off
await test.invoke.updateBoostPermissions(uri, { canIssue: false }, 'test2');
await test2.invoke.sendBoost('test3', uri);

// Permissions cascade down to children via canXChildren
await test.invoke.getBoostPermissions(childUri);
await test.invoke.getBoostPermissions(childUri, 'test2');
await test.invoke.getBoostPermissions(childUri, 'test3');

await test2.invoke.updateBoost(childUri, { category: 'Nice!' });

// You can disable these as well
await test.invoke.updateBoostPermissions(uri, { canEditChildren: '' }, 'test2');
await test2.invoke.updateBoost(uri, { category: 'Aww' });
await test.invoke.getBoostPermissions(childUri, 'test2');
await test.invoke.getBoost(childUri);

// canXChildren permissions can take in a query
await test.invoke.updateBoostPermissions(uri, { canEditChildren: '{ "category": "Nice!" }' }, 'test2');
await test.invoke.getBoostPermissions(childUri, 'test2');
await test2.invoke.updateBoost(childUri, { status: 'LIVE' });

// These permissions are also granular
await test.invoke.sendBoost('test3', childUri);
await test2.invoke.sendBoost('test3', childUri);
await test.invoke.updateBoostPermissions(uri, { canIssueChildren: '' }, 'test2');
await test2.invoke.sendBoost('test3', childUri);

// canXChildren permissions cascade down to grandchildren
await test.invoke.updateBoostPermissions(uri, { canIssueChildren: '*', canEditChildren: '*' }, 'test2');
await test2.invoke.updateBoost(grandChildUri, { status: 'LIVE' });
await test2.invoke.sendBoost('test3', grandChildUri);

// If you remove any parents above the chain though, they stop cascading
await test.invoke.removeBoostParent({ parentUri: uri, childUri });
await test2.invoke.sendBoost('test3', grandChildUri);
await test.invoke.makeBoostParent({ parentUri: uri, childUri });
await test2.invoke.sendBoost('test3', grandChildUri);

📱 🖥 Which devices would you like help testing on?

🧪 Code Coverage

I wrote pretty extensive tests! I tried really hard to make the test titles legible/the source of truth for how permissions work!
image

Documentation

📜 Gitbook

📊 Storybook

✅ PR Checklist

  • Related to a Jira issue (create one if not)
  • My code follows style guidelines (eslint / prettier)
  • I have manually tested common end-2-end cases
  • I have reviewed my code
  • I have commented my code, particularly where ambiguous
  • New and existing unit tests pass locally with my changes
  • I have made corresponding changes to gitbook documentation

🚀 Ready to squash-and-merge?:

  • Code is backwards compatible
  • There is not a "Do Not Merge" label on this PR
  • I have thoughtfully considered the security implications of this change.
  • This change does not expose new public facing endpoints that do not have authentication

Copy link

changeset-bot bot commented Oct 25, 2024

🦋 Changeset detected

Latest commit: f4bcfbb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 33 packages
Name Type
@learncard/network-brain-service Minor
@learncard/network-plugin Minor
@learncard/types Patch
@learncard/network-brain-client Patch
@learncard/init Patch
@learncard/chapi-example Patch
@learncard/snap-example-dapp Patch
@learncard/create-http-bridge Patch
@learncard/cli Patch
@learncard/core Patch
@learncard/helpers Patch
@learncard/react Patch
@learncard/learn-cloud-client Patch
@learncard/ceramic-plugin Patch
@learncard/chapi-plugin Patch
@learncard/did-web-plugin Patch
@learncard/didkey-plugin Patch
@learncard/didkit-plugin Patch
@learncard/idx-plugin Patch
@learncard/learn-card-plugin Patch
@learncard/learn-cloud-plugin Patch
@learncard/vc-api-plugin Patch
@learncard/vc-templates-plugin Patch
@learncard/vc-plugin Patch
@learncard/vpqr-plugin Patch
learn-card-discord-bot Patch
@learncard/meta-mask-snap Patch
@learncard/learn-cloud-service Patch
@learncard/snap-chapi-example Patch
@learncard/expiration-plugin Patch
@learncard/crypto-plugin Patch
@learncard/dynamic-loader-plugin Patch
@learncard/ethereum-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link

netlify bot commented Oct 25, 2024

Deploy Preview for learncarddocs canceled.

Name Link
🔨 Latest commit f4bcfbb
🔍 Latest deploy log https://app.netlify.com/sites/learncarddocs/deploys/67227241a6cf4c00089b496e

Copy link

netlify bot commented Oct 25, 2024

Deploy Preview for learn-card-chapi-example canceled.

Name Link
🔨 Latest commit f4bcfbb
🔍 Latest deploy log https://app.netlify.com/sites/learn-card-chapi-example/deploys/6722724106837e00083d12f6

@smurflo2
Copy link
Collaborator

regarding queries + inheritance for can*Children:

For this situation:

const parentBoost = { category: 'Nice!', ...}
const childBoost = { category: 'Nice!!', ...}
const grandChildBoost = { category: 'Nice!!!', ...}

test2 has permissions like this
await test.invoke.updateBoostPermissions(uri, { canEditChildren: '{ "category": "Nice!!" }' }, 'test2');

(Hmm, okay this is the second time I've typed out a comment then ended up agreeing with the current behavior... I'll finish this thought for posterity...)

Initially I thought that this should result in test2 also being able to edit the grandChild because the ability to edit the child boost would have been inherited by the grandChild.

But now I see that that query set on the parent should be interpreted as "can edit the children that match this query" which makes much more sense than "can edit the children that match this query as well as the whole family tree that branches off of those children"

No change necessary 👍

Copy link
Collaborator

@smurflo2 smurflo2 left a comment

Choose a reason for hiding this comment

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

Wowowow beautiful job on such a gnarly feature. All working flawlessly for me and it all feels like it makes sense and is easy to interact with from the consuming side. And all those tests!

Big kudos 👏👏👏

Update that one error message and we good to go! 🚀

Co-authored-by: Kyle <63376352+smurflo2@users.noreply.github.com>
@TaylorBeeston TaylorBeeston merged commit 859ed57 into main Oct 30, 2024
13 checks passed
@TaylorBeeston TaylorBeeston deleted the LC-339-StackableBoosts branch October 30, 2024 18:03
@github-actions github-actions bot mentioned this pull request Oct 30, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants