Conversation
Introduce a new ApiToken model to replace the single per-course readonly_api_token with a proper token system. Tokens are scoped to courses, linked to users, have expiration dates, usage tracking, and read/write permissions. Token values are stored as SHA-256 digests. Adds an instructor-only management page with a sidebar link, updates the CSV export endpoint for backward compatibility, and includes a rake task to migrate existing legacy tokens. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename find_by_raw_token to find_by_token to avoid Rails/DynamicFindBy - Use range syntax for where clauses (Rails/WhereRange) - Replace update_column with update! (Rails/SkipsModelValidations) - Use change(obj, :attr) style in specs (RSpec/ExpectChange) - Add api_tokens table to schema.rb so CI can set up the database Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Rename ApiToken -> APIToken and ApiTokensController -> APITokensController to match the inflection acronym "API" configured in inflections.rb - Rename find_by_token -> lookup_token to avoid Rails/DynamicFindBy cop Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The presence validation on token_digest runs before the before_create callback, causing "Token digest can't be blank" errors. Switch to before_validation on create so the digest is generated before validation runs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Project doesn't have shoulda-matchers gem, so belong_to matcher is unavailable. Use direct association assertions instead. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
cycomachead
left a comment
There was a problem hiding this comment.
This is looking great. I have not tested this, but I have a few general comments:
- The column name
read_writeas a boolean t/f is very confusing. "Is it a read or a write token? -> "Yes!". The most clear thing seems to me to beread_onlywith a default ofTRUEand not false, for safety. - It is not clear to me if we need both
created_byanduseron a token? Presumably this only makes sense if someone can create tokens on another user's behalf. I don't think we need this feature, and perhaps just having auser_idis simpler a good enough. - I appreciate the fallback code and migration paths. I think we can simplify this.
- We should accept the
readonly_api_tokenquery parameter, but I don't think we need to look up tokens on the courses table. We should be able to assume those are all migrated and will be migrated at deploy time. I would also add a comment that says something like:
# April 2026 - We migrated X Y Z
# Dec 2026 (or later): It is safe to remove the remove the deprecated query parameter and this comment.
Bigger Questions: Current CSV/Google Sheets Flow
Right now, on the All Requests view there buttons that give users links which include the API token pre-filled in the URL. These seem incompatible with this new system because the plaintext/sharable form of the URL is only ever designed or created once.
So, in order to deploy this and merge, I think we have two options:
- We either need to save the pre-digested form of a token in the db. This does have security implications.
- We need to re-architect how tokens are created and explain to users that the URL / Token will only ever be display once, which means they probably need to click twice to get a new URL/token. (e.g. once to start the process and then once to confirm.)
As I am looking at this, since we don't delete the tokens (yet) from the courses controller, we can migrate the tokens, but still display reading them from the existing table and the follow up PRs can handle changing how that page works?
| before_action :set_pending_request_count | ||
|
|
||
| def index | ||
| @side_nav = 'api_tokens' |
There was a problem hiding this comment.
This is a smaller change, but I wonder if we can just use controller_name here? This is an existing pattern, but I wonder if it's actually necessary to set @side_nav
| raw_token = params[:readonly_api_token] || params[:api_token] | ||
|
|
||
| return render plain: 'Invalid or missing API token', status: :unauthorized unless course && ActiveSupport::SecurityUtils.secure_compare(course.readonly_api_token, token.to_s) | ||
| return render plain: 'Course not found', status: :not_found unless course |
There was a problem hiding this comment.
Side note, I wonder if this endpoint should always return a CSV.
In the case of an error maybe it should return something like
Error, Status, Message
True,401|404|etc,the current messages...This would make debugging things easier when they co wrong...
|
|
||
| # Fall back to legacy readonly_api_token for backward compatibility | ||
| if course.readonly_api_token.present? && | ||
| ActiveSupport::SecurityUtils.secure_compare(course.readonly_api_token, raw_token.to_s) |
There was a problem hiding this comment.
I don't think we need this type of fallback, since we can read from the new table.
But genuinely curious here: Have you taken 161 or did you know to use secure compare?
It's probably a good thing to do, but notably isn't done in the process of now making a digest+looking up a token from the new table. Did a LLM just suggest this method or put it in automatically?
I wonder if it really is OK not to use securecompare on the new tokens lookup because we're doing the digest (which takes sometime, but I don't know if's actually timing-attack safe) THEN doing a db query, which should be safer because it's computationally less predictable?
Might be interesting to ask an LLM if it'd make much of a difference. Tbh, this is only of those small, but annoying, easy to forget pieces of security and one we don't talk about much.
| end | ||
|
|
||
| def self.authenticate(raw_token) | ||
| token = lookup_token(raw_token) |
There was a problem hiding this comment.
This is where I wonder if there's any susceptibility to a timing attack or other concern that would have necessitated SecureCompare?
(Not that this type of vulnerability is really a high concern for this kind of application... but I am curious)
| API tokens allow external services to access course data. Each token is scoped to this course and linked to a specific user. | ||
| </div> | ||
|
|
||
| <% if @api_tokens.any? %> |
There was a problem hiding this comment.
Small thing, but I think it's fine to render the table and 0 rows.
We can choose to put the no tokens message below the table still, but I think we can simplify the view code.
| path: course_api_tokens_path(@course.id), | ||
| icon: 'fas fa-key', | ||
| text: 'API Tokens', | ||
| active: @side_nav == 'api_tokens' %> |
There was a problem hiding this comment.
This is where I wonder if we can actually just write controller_name == 'api_tokens or something like this?
Side note, I am thinking we will want to find a place to link API tokens that is more advanced than a top-level link in the sidebar, but this is good for now.
Summary
ApiTokenmodel with course scoping, user association, expiration, usage tracking (last_used_at), read/write permissions, and secure token storage (SHA-256 digest, never plaintext)readonly_api_tokenfor backward compatibilityrake api_tokens:migrate_legacy_tokens) to migrate existing legacy tokens to the new system, linked to the auto-approval system userContext
Previously, each course had a single
readonly_api_tokenstored in plaintext on thecoursestable — no user association, no expiration, no management UI. This PR replaces that with a proper token system that supports multiple tokens per course, each scoped to a user with configurable permissions.Phase 1 (this PR): model + view/delete UI + migration tooling
Phase 2 (future): token creation UI
Related: #155 (Slack approve/deny links will use read+write tokens)
Test plan
rails db:migrateand verifyapi_tokenstable is createdbundle exec rspec spec/models/api_token_spec.rb— model tests passbundle exec rspec spec/controllers/api_tokens_controller_spec.rb— controller tests pass/courses/:id/api_tokensas instructor — see token tablereadonly_api_tokenrake api_tokens:migrate_legacy_tokensand verify tokens are migrated