1

Okay, something is seriously broken here...

I am using Active Model Serializer and Pundit for my Rails 5 JSONAPI server and Ember for my frontend application.

I have User model and Pundit policy for User model which prevent non-authors from viewing unpublished stories and chapters.

At the moment, I am seeing a weird problem which goes like this:

1. UserA creates StoryA, and two published chapters Chapter1 and Chapter2
2. UserA then creates two unpublished chapters Chapter3 and Chapter4
3. UserA logouts
4. UserB logins
5. UserB views the same story created by UserA
6. Server policy kicks in and scope the results to only published chapters since UserB isn't the author.
7. * An SQL DELETE query is sent to delete the two unpublished stories for some odd reason.

Here's some screenshot:

UserA creates 2 published stories and 2 unpublished stories

screenshot 1

Database record shows 4 stories belong to Mount Targon story

screenshot 2

UserA logs out and UserB logs in, views Mount Targon story

screenshot 3

(As you can see, UserB only sees the two published chapters which is correct but...)

The unpublished chapters are deleted from the database for some odd reason

screenshot 4

Looking at the Rails console, I see the DELETE query during a ChaptersController#show CRUD action:

Started GET "/stories/16" for 127.0.0.1 at 2017-11-05 17:02:53 +0800
Processing by StoriesController#show as JSON
  Parameters: {"id"=>"16"}
  User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  Story Load (0.1ms)  SELECT  "stories".* FROM "stories" WHERE "stories"."id" = ? LIMIT ?  [["id", 16], ["LIMIT", 1]]
  Chapter Load (0.3ms)  SELECT "chapters".* FROM "chapters" INNER JOIN "stories" ON "stories"."id" = "chapters"."story_id" INNER JOIN "users" ON "users"."id" = "stories"."user_id" WHERE "chapters"."story_id" = ? AND ((stories.published = 't' AND chapters.published = 't') OR stories.user_id = 2)  [["story_id", 16]]
  Chapter Load (0.1ms)  SELECT "chapters".* FROM "chapters" WHERE "chapters"."story_id" = ?  [["story_id", 16]]
   (0.1ms)  begin transaction
Started GET "/chapters/26" for 127.0.0.1 at 2017-11-05 17:02:53 +0800
  SQL (0.3ms)  DELETE FROM "chapters" WHERE "chapters"."id" = ?  [["id", 32]]
Started GET "/chapters/27" for 127.0.0.1 at 2017-11-05 17:02:53 +0800
Processing by ChaptersController#show as JSON
  SQL (0.1ms)  DELETE FROM "chapters" WHERE "chapters"."id" = ?  [["id", 33]]
Processing by ChaptersController#show as JSON
  Parameters: {"id"=>"26"}
   (2.1ms)  commit transaction
  Parameters: {"id"=>"27"}
  User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
[active_model_serializers]   User Load (0.2ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  Chapter Load (0.1ms)  SELECT  "chapters".* FROM "chapters" WHERE "chapters"."id" = ? LIMIT ?  [["id", 26], ["LIMIT", 1]]
[active_model_serializers]   Story Load (0.4ms)  SELECT "stories".* FROM "stories" WHERE "stories"."user_id" = ?  [["user_id", 1]]
  Chapter Load (0.2ms)  SELECT  "chapters".* FROM "chapters" WHERE "chapters"."id" = ? LIMIT ?  [["id", 27], ["LIMIT", 1]]
Started GET "/chapters/32" for 127.0.0.1 at 2017-11-05 17:02:53 +0800
Started GET "/chapters/33" for 127.0.0.1 at 2017-11-05 17:02:53 +0800
  Story Load (0.2ms)  SELECT  "stories".* FROM "stories" WHERE "stories"."id" = ? LIMIT ?  [["id", 16], ["LIMIT", 1]]
[active_model_serializers] Rendered StorySerializer with ActiveModelSerializers::Adapter::JsonApi (22.64ms)
  Story Load (0.1ms)  SELECT  "stories".* FROM "stories" WHERE "stories"."id" = ? LIMIT ?  [["id", 16], ["LIMIT", 1]]
Processing by ChaptersController#show as JSON
Processing by ChaptersController#show as JSON
[active_model_serializers] Rendered ChapterSerializer with ActiveModelSerializers::Adapter::JsonApi (0.82ms)
Completed 200 OK in 43ms (Views: 27.1ms | ActiveRecord: 3.9ms)

My ChaptersController show action does not even have the word "delete" or "destroy" in it...so how does the record get deleted?

# CHAPTERS CONTROLLER
def show
  chapter = Chapter.find_by(id: params[:id])

  if chapter.present?
    authorize chapter
    render json: chapter, status: :ok
  else
    skip_authorization
    render json: { error: "Chapter not found" }, status: :not_found
  end
end

My Chapter Policy show method:

# CHAPTER PUNDIT POLICY
def show?
  (@record.published? && @record.story.published?) || (@record.story.user == @user)
end

My StoriesController Show action looks like:

# STORIES CONTROLLER
def show
  story = Story.find_by(id: params[:id])

  if story.present?
    authorize story
    story.chapters = policy_scope(story.chapters)
    render json: story, include: [:user, :chapters], status: :ok
  else
    skip_authorization
    render json: { errors: "Story not found" }, status: :not_found
  end
end

I thought it might be Ember doing some funny extra query behind the scenes but I use Postman Mac application to test viewing the story and sure enough, the unpublished chapters are deleted without going through Ember at all. It's happening server side for some odd reason =/

Any ideas?

Zhang
  • 11,549
  • 7
  • 57
  • 87

2 Answers2

2

It looks to me like this is actually being done in StoriesController#show. You should try again testing only this method (in RSpec or something), and you can also step through the code to pinpoint exactly where the delete is occurring. My guess is:

story.chapters = policy_scope(story.chapters)

You're changing the story chapters here. Presumably you want to do something like

@displayed_chapters = policy_scope(story.chapters)
oowowaee
  • 1,635
  • 3
  • 16
  • 24
  • Commenting out the line to become `# story.chapters = policy_scope(story.chapters)` does indeed prevent the record from being deleted in the database. I need to filter the chapters according to the Pundit policy otherwise, my server returns all chapters including unpublished chapters even for users that did not create the story. Is there a proper way to filter out the data? What's more bizarre is, I'm not calling `story.save` after `story.chapters = policy_scope(story.chapters)` so why does the story save itself? – Zhang Nov 06 '17 at 04:48
  • 1
    Okay, nevermind, I got my answer to why it auto-saved here: https://gist.github.com/demisx/9896113 The docs say: `When you assign an object to a has_many association, that object is automatically saved (in order to update its foreign key). If you assign multiple objects in one statement, then they are all saved.` – Zhang Nov 06 '17 at 05:04
  • Now I need to work out how to filter my include fields in this line render json: story, include: [:user, :chapters], status: :ok so that the story.chapters does not return unpublished chapters if user is not the author =/ – Zhang Nov 06 '17 at 05:45
  • @Zhang I would assume you could pass the authorized comments as an argument to the serializer, or else override the comments field in the serializer and authorize it there (not sure which is more feesible off the top of my head - but clearly someone else has had this problem before :) – oowowaee Nov 06 '17 at 15:19
  • Not sure if anyone else have come across this problem before, my search results didn't return anything similar. In the Pundit docs, they did have a section about using `policy_scope(@post.comments)` in the Rails erb html views but my frontend is built with Ember, not Rails, so I can't do that with Ember. I've even tried replacing Active Model Serializer with `jsonrails-rb` gem for rendering to JSONAPI, result is still the same. I've posted this isssue on Pundit github issue tracker. This use case seems so straight forward, I would be baffled if the Pundit devs says theres no way to do it. – Zhang Nov 06 '17 at 15:40
  • @Zhang when I looked at it, it seems well documented how to pass additional variables to serializers https://github.com/rails-api/active_model_serializers/issues/599, and use Pundit outside of a controller https://github.com/elabs/pundit#manually-retrieving-policies-and-scopes. – oowowaee Nov 06 '17 at 15:42
  • :D I wish I could reach over there and give you a hug lol. Yes, it's finally working now, I wasn't aware you could override a `has_many :chapters` with a method inside the serializer. Syntax needs to be `policy_scope(object.chapters)` instead of `policy_scope(self.chapters)` – Zhang Nov 06 '17 at 15:50
0

There's nothing that stands out as a possible source of a DELETE. I think the solution is some good, old-fashioned debugging.

  • Trying commenting out all the lines in the action. Then uncomment one one by.
  • Look for the transaction mentioned in the logs
  • Subscribe to sql queries and raise when the query is delete and look at the stacktrace. Or override deleting altogether.

If the delete is happened in the serializer or policy, it's something in userland code that is making it happen. Neither library even knows about active record.

BF4
  • 1,076
  • 7
  • 23