Skip to content

Support service-authenticated Experience CS project and asset syncs - #973

Merged
abcampo-iry merged 6 commits into
mainfrom
issues/1658
Aug 21, 2026
Merged

Support service-authenticated Experience CS project and asset syncs#973
abcampo-iry merged 6 commits into
mainfrom
issues/1658

Conversation

@abcampo-iry

@abcampo-iry abcampo-iry commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Status

Points for consideration

Security

  • Creates a new key only used for assets upload and sync.
  • Restricts the service identity to public Scratch projects, rejects user-owned, school-owned, and non-Scratch projects and limits asset uploads to public Code Classroom projects.

Performance

  • Checks stored checksums without downloading assets.
  • Serializes writes to prevent conflicting or duplicate uploads.

What’s changed?

  • Added EXPERIENCE_CS_API_KEY configuration.
  • Added authentication through the X-Experience-CS-API-Key header.
  • Allowed Experience CS to create and update public Scratch projects.
  • Allowed global asset uploads for public Code Classroom projects.
  • Made asset uploads idempotent.

Steps to perform after deploying to production

  • ⚠️ Configure EXPERIENCE_CS_API_KEY with a secure secret.
  • Deploy Editor API before enabling the Experience CS synchronization.
  • Configure the same value as EDITOR_API_SYNC_API_KEY in Experience CS.

@cla-bot cla-bot Bot added the cla-signed label Aug 19, 2026
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown

Test coverage

93.41% line coverage reported by SimpleCov.
Run: https://github.com/RaspberryPiFoundation/editor-api/actions/runs/32385744925

@raspberrypiherokubot
raspberrypiherokubot temporarily deployed to editor-api-p-issues-165-icuvww August 19, 2026 09:57 Inactive
@abcampo-iry abcampo-iry changed the title Issues/1658 Support service-authenticated Experience CS project and asset syncs Aug 19, 2026
@abcampo-iry
abcampo-iry marked this pull request as ready for review August 20, 2026 07:18
Copilot AI lite review requested due to automatic review settings August 20, 2026 07:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a service-authenticated pathway for Experience CS to sync public Scratch projects and upload global Scratch assets, integrating with existing Rails API auth/authorization patterns while keeping user- and school-owned resources restricted.

Changes:

  • Introduces EXPERIENCE_CS_API_KEY config + X-Experience-CS-API-Key header authentication via ExperienceCsServiceAuthenticator.
  • Restricts project create/update for the service identity to public Scratch project types, and adds a dedicated global Scratch asset upload endpoint.
  • Adds/extends request and feature specs plus updates docs and .env.example.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
app/services/experience_cs_service_authenticator.rb Adds service-key authenticator that returns an Experience CS admin service user.
app/controllers/concerns/identifiable.rb Adds a loader to set current_user from the Experience CS service header.
app/controllers/api/projects_controller.rb Allows service-authenticated create/update while enforcing “public Scratch only” constraints for the service identity.
app/controllers/api/scratch/assets_controller.rb Adds create_global to upload/repair idempotent global assets with checksum conflict detection.
config/routes.rb Adds route for global Scratch asset upload endpoint.
config/application.rb Wires EXPERIENCE_CS_API_KEY into config.x.experience_cs.service_api_key.
README.md Documents Experience CS sync behavior and configuration/header usage.
.env.example Adds EXPERIENCE_CS_API_KEY example value and comment.
spec/services/experience_cs_service_authenticator_spec.rb Adds unit coverage for service authentication success/failure cases.
spec/requests/projects/update_spec.rb Adds request specs for service-authenticated update constraints.
spec/features/project/creating_a_project_spec.rb Adds feature coverage for service-authenticated project creation constraints.
spec/features/scratch/creating_and_showing_a_scratch_asset_spec.rb Adds feature coverage for global asset upload behavior (idempotent/conflict/repair/access).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread app/services/experience_cs_service_authenticator.rb
@zetter-rpf zetter-rpf assigned zetter-rpf and unassigned zetter-rpf Aug 20, 2026
@zetter-rpf
zetter-rpf self-requested a review August 20, 2026 07:59
Comment on lines +89 to +91
def global_file_matches?(scratch_asset)
scratch_asset.file.blob.checksum == request_body_checksum
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What's the reasoning behind needing to hash the assets?

The asset name should already be the hash of the asset so I'm wondering if it's simpler to use that and skip saving it if the asset already exists.

Since global assets can only be created by trusted systems I think the risk is low (but I might be missing something?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For me the key is to compare checksums to avoid silently using the wrong asset if different content is uploaded with the same filename. It's quite inexpensive that check but you are right risk is low

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If it's reliable that ok - a reason to remove it would be if we didn't think it could hash files consistently because some part of them changed even though Scratch think it's the same file.

Comment on lines +77 to +82
def experience_cs_service_project?
return public_scratch_project_attributes?(experience_cs_service_project_attributes) if action_name == 'create'

public_scratch_project_attributes?(@project.attributes.symbolize_keys) &&
public_scratch_project_attributes?(experience_cs_service_project_attributes)
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I found this harder to follow what this was doing, I think it might be due to the naming of the public_scratch_project_attributes?

Could some of this be pushed into another object or the project model? Maybe a .is_public_experience_cs_project? or similar? I'm not sure if it it would work well as validation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I moved this logic to Project.public_experience_cs_project?
I don’t think it should be a validation because the project can be valid in other contexts; this rule only limits what Experience CS can sync. Maybe if later we decide to be more global we can move it

private

def authorize_experience_cs_service_project
return unless current_user&.id == ExperienceCsServiceAuthenticator::USER_ID

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This could be pushed onto the user to remove it from having to check the specific USER id - user.experience_cs_service_account?

@abcampo-iry abcampo-iry Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added User.experience_cs_service_account?

raise CanCan::AccessDenied
end

def experience_cs_service_project?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Small naming thing - is the service part important here - might be easier to read if it was just experience_cs_project? unless it's specifically about the service account?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It applies specifically to Experience CS syncs,
renamed it to experience_cs_service_project_change_permitted?
we can use cs_syncs if you think is more clear

@zetter-rpf

Copy link
Copy Markdown
Contributor

I like the approach, and I think creating the 'experience cs user' is a good way to make it work with the existing controllers.

I've added suggestions for improvements - let me know if you want to talk through any and I'll re-review after any changes since I want to be able to check the authorization logic again before mergin.

@abcampo-iry
abcampo-iry requested a review from zetter-rpf August 20, 2026 11:36
Comment on lines +38 to +39
authorize! :create, @project_from_header
raise CanCan::AccessDenied unless experience_cs_public_project?

@zetter-rpf zetter-rpf Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this check useful? If we're not saving a asset against a project, what's the reason in authorizing the project and checking that it's an experience CS one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. We should authorize the global asset upload, not project creation. We still check the project to ensure the asset is being uploaded for a valid Experience CS project.

@zetter-rpf zetter-rpf Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should authorize the global asset upload

Agreed, I think it's allowed if the user is the experience CS admin using the shared secret?

We still check the project to ensure the asset is being uploaded for a valid Experience CS project.

This I'm still not sure about - if the asset isn't linked to a project, then I don't see any point in sending the project details or checking them. I could see the argument that the asset should be linked to a project, which we could do, we would just need to make sure that assets on public projects were still visible to all.

Comment on lines +38 to +39
authorize! :create_global, ScratchAsset
raise CanCan::AccessDenied unless @project_from_header.public_experience_cs_project?

@zetter-rpf zetter-rpf Aug 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I talked to @abcampo-iry about this we agreed that there checking the project isn't useful here so can be removed. I was worried it might create a false sense of security, and removing it lets us slightly simplify the api call. This project check would be useful if we were saving the asset on the project.

@abcampo-iry
abcampo-iry merged commit 2f054c3 into main Aug 21, 2026
8 checks passed
@abcampo-iry
abcampo-iry deleted the issues/1658 branch August 21, 2026 08:44
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.

4 participants