Skip to content

Fix authorization in Api::ProjectsController #553

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

floehopper
Copy link
Contributor

@floehopper floehopper commented May 25, 2025

Previously this spec was not representative of how the API was being used in practice and it was only really working by accident. The PUT requests in the examples were using the Project#id rather than the Project#identifier as they should have been.

The spec was working even though the Api::ProjectsController#load_project before action was not finding
the project, because the load_and_authorize_resource before action was then finding the project using Project.find(params[:id]) and setting the @project instance variable used by the rest of the logic in the controller.

However, editor-standalone uses the project identifier as the resource ID in the URL path, so this spec was completely unrepresentative of how the endpoint was really being used.

I've changed the examples to use the Project#identifier as the resource ID in the PUT requests. This means that the Api::ProjectsController#load_project before action now finds the project by identifier and since it sets the @project instance variable the load_and_authorize_resource before action no longer attempts to load the resource, it just does the authorize step using the already loaded project.

In order to make this work reliably, I had to explicitly set the Project#locale to one of the fallback locales in ProjectLoader, i.e. 'en' or nil. Otherwise, the random locale selected in the project factory meant that sometimes the load_project before action (which uses the ProjectLoader did not find the project and the load_and_authorize_resource before action raised an ActiveRecord::RecordNotFound resulting in a 404 Not Found.

Now the spec is no longer making use of the random locale from the factory, I've taken the opportunity to add some new examples demonstrating the behaviour when the project has different locales. This effectively demonstrates that load_project is wired up to ProjectLoader correctly.

To make it clearer the load_project before action is where we want the project to be loaded, I've changed the load_and_authorize_resource before action to authorize_resource, so the CanCanCan authorization uses the project found by the load_project before action.

However, this meant that if the project was not found by the load_project before action an exception was raised in Project::Update.call resulting in a 422 Unprocessable Entity response with the following error message:

Error persisting changes: undefined method `components' for nil:NilClass

To fix this I'm now raising an ActiveRecord::RecordNotFound exception in the load_project before action if no project is found. This results in the expected 404 Not Found response.

I think there's a strong case to be made the this exception raising behaviour should be added to ProjectLoader#load. However, that's a bigger change with more risk, so I'm going to leave that for now.

Note that I've retained the load resource functionality for the create action, because the load_project before action isn't triggered for create and the authorize resource functionality seems to rely on the project built by the load resource step and I want to keep changes to a minimum.

I'm confident that no clients of editor-api are trying to find projects by Project#id as opposed to Project#identifier, because Project#id is never exposed in Project-related JSON responses, e.g. in app/views/api/projects/show.json.jbuilder nor in any of the JSON views in app/views/api/projects/.

@cla-bot cla-bot bot added the cla-signed label May 25, 2025
@floehopper floehopper force-pushed the fix-authorization-in-api-projects-controller branch from e62097e to ed1be1b Compare May 25, 2025 16:54
@raspberrypiherokubot raspberrypiherokubot temporarily deployed to editor-api-p-fix-author-tua1dw May 25, 2025 16:58 Inactive
@floehopper floehopper marked this pull request as ready for review May 27, 2025 07:17
@chrisroos chrisroos self-requested a review May 27, 2025 09:48
floehopper added a commit that referenced this pull request May 27, 2025
This gives users with the "experience-cs-admin" role permission to
update starter (or "public") projects (i.e. projects with no `user_id`
set). I've added an example to the "Updating a project" feature spec to
check this works as intended.

Note that in the feature spec, unlike in the other examples, I'm relying
on the lookup via `ProjectLoader#load` which I think is the intended use
(at least from Experience CS) and this is why I've had to set the
project locale to one of the default fallback locales, i.e. 'en', in the
spec setup. I haven't attempted to fix the other examples, but I've
started looking at that in #553 and plan to address the problem
separately.
@floehopper
Copy link
Contributor Author

Having discussed this with @chrisroos, I've decided to park this until we have time to go through the various clients of editor-api and see whether any of them might be relying on being able to lookup projects by Project#id vs Project#identifier. Marking as draft.

@floehopper floehopper marked this pull request as draft May 27, 2025 11:43
floehopper added a commit that referenced this pull request May 27, 2025
After a review by @chrisroos, we agreed a user with the
"experience-cs-admin" role should:

1. Be able to manage starter (or public) projects, i.e. those that have
  `user_id` set to `nil`.
2. Be able to manage their own projects, i.e. those that have a `user_id`
  matching `User#id`.
3. Not be able to manage another user's projects, i.e. those that have a
  `user_id` that does not match `User#id`.

I've expanded the examples in the `Ability` spec to cover these
scenarios and amended the rules to conform with the spec.

I'm taking "manage" permission as equivalent to the combination of read,
create, update & destroy which covers all the standard RESTful
controller actions.

Point 1 was mostly already covered, except for read permission which
allows access to show & index actions, so I've added that.

Point 2 was already covered by permissions defined in
`Ability#define_authenticated_non_student_abilities`.

I've addressed point 3 by adding the `user_id: nil` constraint to the
rules defined in `Ability#define_experience_cs_admin_abilities`.

I've fixed the relevant examples in
`spec/features/project/updating_a_project_spec.rb` by changing the
project to be a starter project.

I've tweaked the wording of the contexts in the three specs to clarify
that they're about an Experience CS admin creating, updating &
destroying a starter Scratch project which is our use case.

Despite the confusion around `load_and_authorize_resource` discussed
in #553, we're pretty confident that these CanCanCan rules are working
as intended in `Api::ProjectsController`. And the specs seem to back
that up.
floehopper added a commit that referenced this pull request May 28, 2025
This gives users with the "experience-cs-admin" role permission to
update starter (or "public") projects (i.e. projects with no `user_id`
set). I've added an example to the "Updating a project" feature spec to
check this works as intended.

Note that in the feature spec, unlike in the other examples, I'm relying
on the lookup via `ProjectLoader#load` which I think is the intended use
(at least from Experience CS) and this is why I've had to set the
project locale to one of the default fallback locales, i.e. 'en', in the
spec setup. I haven't attempted to fix the other examples, but I've
started looking at that in #553 and plan to address the problem
separately.
floehopper added a commit that referenced this pull request May 28, 2025
After a review by @chrisroos, we agreed a user with the
"experience-cs-admin" role should:

1. Be able to manage starter (or public) projects, i.e. those that have
  `user_id` set to `nil`.
2. Be able to manage their own projects, i.e. those that have a `user_id`
  matching `User#id`.
3. Not be able to manage another user's projects, i.e. those that have a
  `user_id` that does not match `User#id`.

I've expanded the examples in the `Ability` spec to cover these
scenarios and amended the rules to conform with the spec.

I'm taking "manage" permission as equivalent to the combination of read,
create, update & destroy which covers all the standard RESTful
controller actions.

Point 1 was mostly already covered, except for read permission which
allows access to show & index actions, so I've added that.

Point 2 was already covered by permissions defined in
`Ability#define_authenticated_non_student_abilities`.

I've addressed point 3 by adding the `user_id: nil` constraint to the
rules defined in `Ability#define_experience_cs_admin_abilities`.

I've fixed the relevant examples in
`spec/features/project/updating_a_project_spec.rb` by changing the
project to be a starter project.

I've tweaked the wording of the contexts in the three specs to clarify
that they're about an Experience CS admin creating, updating &
destroying a starter Scratch project which is our use case.

Despite the confusion around `load_and_authorize_resource` discussed
in #553, we're pretty confident that these CanCanCan rules are working
as intended in `Api::ProjectsController`. And the specs seem to back
that up.
@floehopper
Copy link
Contributor Author

@chrisroos

Having discussed this with @chrisroos, I've decided to park this until we have time to go through the various clients of editor-api and see whether any of them might be relying on being able to lookup projects by Project#id vs Project#identifier. Marking as draft.

I've just added the following paragraph to the PR description:

I'm confident that no clients of editor-api are trying to find projects by Project#id as opposed to Project#identifier, because Project#id is never exposed in Project-related JSON responses, e.g. in app/views/api/projects/show.json.jbuilder nor in any of the JSON views in app/views/api/projects/.

Is that enough to convince you that this is a safe change to make?

Marking as ready for review.

@floehopper floehopper marked this pull request as ready for review July 10, 2025 07:41
@chrisroos
Copy link
Contributor

Is that enough to convince you that this is a safe change to make?

I think we'd have to inspect the logs to be really confident that IDs aren't being used in these requests. But I agree that it seems unlikely that IDs are being used.

I've been looking through the history and think I have a better idea of what might've happened:

Prior to ce88a04 ("Make component content nullable" on 7th July 2022) the Api::ProjectsController was only finding projects by Project#identifier. Commit ce88a04 added both before_action :load_project (which was DRYing up calls to Project.find_by!(identifier: params[:id])) and CanCanCan's load_and_authorize_resource. So I think it's this commit where it became possible to find a project by Project#id instead of only Project#identifier. The specs weren't updated in this commit to find projects by Project#id which could be interpreted to mean that it was an oversight to use load_and_authorize_resource instead of just authorize_resource.

Commit 4dc6d78 ("Project serve based on lang (#160)" on 13th March 2023) updated the load_project before action to use the ProjectLoader so that it took the identifier and locale into account when finding the project.

Commit ebaa275 ("Add partial feature tests for updating a project" on 25th Feb 2024) added spec/features/project/updating_a_project_spec.rb (the spec updated in this PR) which used Project#id instead of Project#identifier. I can only assume this was an oversight because at this point in time spec/requests/projects/update_spec.rb (and any other spec dealing with Api::ProjectsController were using Project#identifier).

So, although it's been possible to find projects by their ID since 13th March 2023, I agree that it's unlikely to have been used given that the project IDs aren't being exposed as far as we can tell.

@floehopper floehopper force-pushed the fix-authorization-in-api-projects-controller branch 2 times, most recently from 1ff08c9 to b48f478 Compare July 10, 2025 12:16
This is more idiomatic and thus easier to read.
This value doesn't change in any of the contexts, so using a `let` block
is of limited value. Also it will avoid triggering a violation of
`RSpec/MultipleMemoizedHelpers` in a later commit.
Previously this spec was not representative of how the API was being
used in practice and it was only really working by accident. The PUT
requests in the examples were using the `Project#id` rather than the
`Project#identifier` as they should have been.

The spec was working even though the
`Api::ProjectsController#load_project` before action was *not* finding
the project, because the `load_and_authorize_resource` before action was
then finding the project using `Project.find(params[:id])` and setting
the `@project` instance variable used by the rest of the logic in the
controller.

However, clients of editor-api like editor-standalone use the project
identifier as the resource ID in the URL path [1], so this spec was
completely unrepresentative of how the endpoint was really being used.

In this commit I've changed the examples to use the `Project#identifier`
as the resource ID in the PUT requests. This means that the
`Api::ProjectsController#load_project` before action now finds the
project by identifier and since it sets the `@project` instance variable
the `load_and_authorize_resource` before action no longer attempts to
load the resource, it just does the authorize step using the already
loaded project.

In order to make this work reliably, I had to explicitly set the
`Project#locale` to one of the fallback locales in `ProjectLoader` [2],
i.e. 'en' or `nil`. Otherwise, the random locale selected in the project
factory [3] meant that sometimes the `load_project` before action (which
uses the `ProjectLoader` did not find the project and the
`load_and_authorize_resource` before action raised an
`ActiveRecord::RecordNotFound` resulting in a 404 Not Found.

Now the spec is no longer making use of the randome locale from the
factory, I've taken the opportunity to add some new examples
demonstrating the behaviour when the project has different locales. This
effectively demonstrates that `load_project` is wired up to
`ProjectLoader` correctly.

As an aside, I'm not convinced that having the factory select a locale
at random is a good idea. I found it very confusing when it led to
undeterministic specs. However, I'll leave that for another time.

[1]: https://github.com/RaspberryPiFoundation/editor-standalone/blob/1d4375635cb6890794732072d608dbd4b05b3bb0/src/utils/apiCallHandler/projects.js#L16
[2]: https://github.com/RaspberryPiFoundation/editor-api/blob/b4bd337d09a88b1f41ecdc13136f7d11da0dcf89/lib/project_loader.rb#L8
[3]: https://github.com/RaspberryPiFoundation/editor-api/blob/b4bd337d09a88b1f41ecdc13136f7d11da0dcf89/spec/factories/project.rb#L9
As I explained in the previous commit, the `load_project` before action
is where we want the project to be loaded, i.e. via `ProjectLoader` so
that it's found by a combination of `Project#identifier` and
`Project#locale`.

To make this clearer, I've changed the `load_and_authorize_resource`
before action to `authorize_resource` [1], so the CanCanCan
authorization uses the project found by the `load_project` before
action.

However, this meant that if the project was *not* found by the
`load_project` before action an exception was raised in
`Project::Update.call` resulting in a 422 Unprocessable Entity response
with the following error message:

    Error persisting changes: undefined method `components' for nil:NilClass

To fix this I'm now raising an `ActiveRecord::RecordNotFound` exception
in the `load_project` before action if no project is found. This results
in the expected 404 Not Found response.

I think there's a strong case to be made the this exception raising
behaviour should be added to `ProjectLoader#load`. However, that's a
bigger change with a lot more risk, so I'm going to leave that for now.

Note that I've retained the load resource functionality for the `create`
action, because the `load_project` before action isn't triggered for
`create` and the authorize resource functionality seems to rely on the
project built by the load resource step and I want to keep changes to a
minimum.

[1]: https://github.com/CanCanCommunity/cancancan/blob/3.4.0/docs/controller_helpers.md#authorize_resource-load_resource-load_and_authorize_resource
@floehopper floehopper force-pushed the fix-authorization-in-api-projects-controller branch from b48f478 to 485e5de Compare July 10, 2025 12:18
@floehopper
Copy link
Contributor Author

@chrisroos

I've been looking through the history and think I have a better idea of what might've happened:

Amazing detective work! That does give me more confidence.

I've now rebased this against main which forced me to add Move let into private method in Updating a project spec to avoid triggering a Rubocop cop violation in one of the later commits.

Copy link
Contributor

@chrisroos chrisroos left a comment

Choose a reason for hiding this comment

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

Looks good to me, @floehopper!

@raspberrypiherokubot raspberrypiherokubot temporarily deployed to editor-api-p-fix-author-fls0u3 July 10, 2025 12:24 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants