Is there another term for covering permissions that based on “ownership” on top of roles?

This question comes from

Charlie_Today at 2:33 AM

The cookbook covers RBAC, Is there another term for covering permissions that based on “ownership” on top of roles? I.e. Bob has the role of “writer” but can only edit blog posts that he created?

I figure I could have a go at making something like the hoc but does these kinds of ownership checks instead of only roles, but don’t want to re-invent the wheel if something like this already exists for redwood.

First, let me point to an existing topic that can also make for some context:

Thanks for reading the RBAC Cookbook – and you are correct; the roles do not contain any info or rules to answer or enforce: “Bob has the role of “writer” but can only edit blog posts that he created?”

In the RedwoodJS docs for Auth0, I mention:

Role-based access control (RBAC) refers to the idea of assigning permissions to users based on their role within an organization. It provides fine-grained control and offers a simple, manageable approach to access management that is less prone to error than assigning permissions to users individually.

Essentially, a role is a collection of permissions that you can apply to users. A role might be “admin”, “editor” or “publisher”. This differs from permissions an example of which might be “publish:blog”.

These “permissions” or “scopes” further define what a publisher (a role) may publish (blogs, but not alerts).

But that doesn’t yet get the to the question you asked: “Bob has the role of “writer” but can only edit blog posts that he created?”

I haven’t written a Rails app in some years, but when I did and had this problem to solve, I used a gem called CanCan :dancer:

It’s important to note that CanCan itself says:

CanCan is decoupled from how you implement roles in the User model

That’s another job – part of authentication and other rules. Here it is just trying to deal with permissions and sets of permissions based on rules (like role or other info).

CanCan dubbed an Ability as the rule “where all user permissions are defined.”

Here’s an example:

class Ability
  include CanCan::Ability

  def initialize(user)
    user ||= User.new # guest user (not logged in)
    if user.admin?
      can :manage, :all
    else
      can :read, :all
    end
  end
end

You can see it does a few things:

  • are they authenticated?
  • do they have the admin role?
  • if, so then “can” manage everything (ie all crud)
  • no, well then just read

The in code you’d check if user can(:update, something).

OK, closer … but still not the answer.

You can see that it’s not a huge leap to create a rule or ability that says:

  • I am Bob
  • I am a writer
  • I can read and add blog posts
  • But, I cannot delete them
  • and I can only edit/update ones where I can am the author (aka post.authorId = currentUser.id)

And that’s the rule you need to check and enforce.

Enforcing would be at the service level, perhaps going from:

export const updatePost = ({ id, input }) => {
  requireAuth({ roles: UPDATE_POST_ROLES })

  return db.post.update({
    data: {
      ...input,
      editorId: context.currentUser.sub,
      publisherId: context.currentUser.sub,
      updatedAt: new Date(),
    },
    where: { id },
  })
}

to

export const updatePost = ({ id, input }) => {
  requireAuth({ roles: UPDATE_POST_ROLES })

  return db.post.update({
    data: {
      ...input,
      editorId: context.currentUser.sub,
      publisherId: context.currentUser.sub,
      updatedAt: new Date(),
    },
    where: { id, authorId: context.currentUser.sub },
  })
}

Subtle difference is that when fetching the post that is trying to be updated, check that the post has the user’s authorId.

or maybe

export const updatePost = ({ id, input }) => {
  requireAuth({ roles: UPDATE_POST_ROLES })

  const post = db.post.update({
    data: {
      ...input,
      editorId: context.currentUser.sub,
      publisherId: context.currentUser.sub,
      updatedAt: new Date(),
    },
    where: { id, authorId: context.currentUser.sub },
  })

  if (post === undefined) {
    throw new ForbiddenError("You don't have access to do that.")
  }
  
  return post
}

or even

export const updatePost = ({ id, input }) => {
  requireAuth({ roles: UPDATE_POST_ROLES })

  const post = db.post.findOne({
    where: { id, authorId: context.currentUser.sub },
  })

  if (post === undefined) {
    throw new ForbiddenError("You don't have access to do that.")
  }

  return db.post.update({
    data: {
      ...input,
      editorId: context.currentUser.sub,
      publisherId: context.currentUser.sub,
      updatedAt: new Date(),
    },
    where: { id },
  })
}

and at that point you may want to bake in:

requireAuth({ roles: UPDATE_POST_ROLES })

  const post = db.post.findOne({
    where: { id, authorId: context.currentUser.sub },
  })

  if (post === undefined) {
    throw new ForbiddenError("You don't have access to do that.")
  }

into its own canEditPost() method as some ability.

And then you’d of course have to do some of this rule checking on the web side, too. Always do both.

TLDR;

There’s Authentication and Authorization. Authorization can have roles and permissions. Currently RedwoodJS provides the foundation to extract role info from auth providers (or roll your own :smile: ) but does not yet have a pattern for the permissions or scopes because well those rules are very app specific. But, I imagine in near future it will have some opinions and patterns to choose from.

4 Likes

Thanks for such a detailed response. I’ll see how I go :crossed_fingers:

1 Like

What I’ve done to add some “ownership” permissions is, first off I’ve made a requireOwnership util to match requireAuth. The API is passing in an id of something a userId, partId (all optional but needs one), and it checks that the current user “owns” that resource. Here’s an example (https://github.com/Irev-Dev/cadhub/blob/main/api/src/services/parts/parts.js)

export const updatePart = async ({ id, input }) => {
  requireAuth()
  await requireOwnership({ partId: id })
  if (input.title) {
    input.title = enforceAlphaNumeric(input.title)
  }
  return db.part.update({
    data: foreignKeyReplacement(input),
    where: { id },
  })
}

One important thing to point out is that requireOwnership needs to be awaited since it needs to do some db reads to check township.
requireOwnership (https://github.com/Irev-Dev/cadhub/blob/main/api/src/lib/owner.js)

export const requireOwnership = async ({ userId, userName, partId } = {}) => {
  if (!userId && !userName && !partId) {
    throw new ForbiddenError("You don't have access to do that.")
  }
  if (context.currentUser.roles?.includes('admin')) {
    return
  }
  const netlifyUserId = context.currentUser?.sub
  if (userId && userId !== netlifyUserId) {
    throw new ForbiddenError("You don't own this resource.")
  }
  if (userName) {
    const user = await db.user.findOne({
      where: { userName },
    })

    if (!user || user.id !== netlifyUserId) {
      throw new ForbiddenError("You don't own this resource.")
    }
  }
  if (partId) {
    const user = await db.part
      .findOne({
        where: { id: partId },
      })
      .user()
    if (!user || user.id !== netlifyUserId) {
      throw new ForbiddenError("You don't own this resource.")
    }
  }
}

In my app so far everything is public, so I only need to guard against editing something you don’t own, not viewing, so in all of my edit routes I have a redirecting useEffect similar to this, that redirects to to the non-edit page. (https://github.com/Irev-Dev/cadhub/blob/main/web/src/components/PartProfile/PartProfile.js)

const canEdit = currentUser?.sub === partOwner.id
  useEffect(() => {
    !canEdit &&
      navigate(
        routes.part({ userName: partOwner.userName, partTitle: part.title })
      )
  }, [currentUser, canEdit])

That’s says nothing about my db schema, but I think it goes without saying that parts are linked to a user etc.

3 Likes

@Irev-Dev I wonder how you might test a service that makes use of requireOwnership()?

Example:

things.js

export const updateThing = async ({ userId, thingId, input }) => {
  await requireOwnership({ userId, thingId })
  return db.thing.update({
    data: input,
    where: { thingId },
  })
}

thing.test.js

scenario('updates a thing', async (scenario) => {
    const original = await thing({
      thingId: scenario.thing.one.id,
      name: 'Test',
      userId: '1',
    })
    const result = await updateThing({
      userId: '1',
      thingId: original.id,
      input: { name: 'Test2' },
    })

    expect(result.name).toEqual('Test2')
  })

This results in a ForbiddenError because context.currentUser?.sub is undefined when running the test.

Hey @darryl-snow, here’s one way of mocking out current user

You just have to make sure you call it before your describe block.

jest.mock('@redwoodjs/api', () => {
  return {
    context: {
      currentUser: {
        activeSubscription: null,
        id: 'mocked-user-123',
        email: 'mockedemail@example.com',
        name: 'Mockity McMockface',
        teamMember: [
          {
            id: 'team-member-id-123',
            role: 'ADMIN',
            teamId: 'mocked-team-id',
            userId: 'mocked-user-123',
            team: {
              id: 'teamid',
              name: 'mocked team',
            },
          },
        ],
      },
    },
  }
})

Right now I’m doing one mock like this per test file, and one scenario to go along with it. To mock a user per test is a little more hassle because after you call jest.mock you will need to re-import the function you’re testing, otherwise the mocks won’t have been updated.

e.g. tapes.test.ts, tapes.scenario.ts is one set, with one mocked user limits.test.ts, limits.scenario.ts is another

1 Like

Got it, thank you!

1 Like

Hello - just an update on this. v0.37.x will include a fix where you can mock current user in your scenario/api tests using :drum:mockCurrentUser() - no imports necessary!

Alternatively you can also mock the entire context using setContext

1 Like