Skip to content

Add API token management system#344

Open
ba88im wants to merge 5 commits intomainfrom
ba88im-superset/ba88im/api-tokens
Open

Add API token management system#344
ba88im wants to merge 5 commits intomainfrom
ba88im-superset/ba88im/api-tokens

Conversation

@ba88im
Copy link
Copy Markdown

@ba88im ba88im commented Apr 9, 2026

Summary

  • Adds a new ApiToken model with course scoping, user association, expiration, usage tracking (last_used_at), read/write permissions, and secure token storage (SHA-256 digest, never plaintext)
  • Adds an instructor-only "API Tokens" management page (view + revoke) accessible from the course sidebar
  • Updates the CSV export endpoint to support both the new token system and the legacy readonly_api_token for backward compatibility
  • Includes a rake task (rake api_tokens:migrate_legacy_tokens) to migrate existing legacy tokens to the new system, linked to the auto-approval system user

Context

Previously, each course had a single readonly_api_token stored in plaintext on the courses table — 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

  • Run rails db:migrate and verify api_tokens table is created
  • Run bundle exec rspec spec/models/api_token_spec.rb — model tests pass
  • Run bundle exec rspec spec/controllers/api_tokens_controller_spec.rb — controller tests pass
  • Navigate to /courses/:id/api_tokens as instructor — see token table
  • Verify students cannot access the API Tokens page
  • Revoke a token and verify it shows as revoked
  • Verify CSV export still works with legacy readonly_api_token
  • Run rake api_tokens:migrate_legacy_tokens and verify tokens are migrated

ba88im and others added 5 commits April 8, 2026 17:28
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>
Copy link
Copy Markdown
Member

@cycomachead cycomachead left a comment

Choose a reason for hiding this comment

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

This is looking great. I have not tested this, but I have a few general comments:

  • The column name read_write as a boolean t/f is very confusing. "Is it a read or a write token? -> "Yes!". The most clear thing seems to me to be read_only with a default of TRUE and not false, for safety.
  • It is not clear to me if we need both created_by and user on 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 a user_id is simpler a good enough.
  • I appreciate the fallback code and migration paths. I think we can simplify this.
  • We should accept the readonly_api_token query 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'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread app/models/api_token.rb
end

def self.authenticate(raw_token)
token = lookup_token(raw_token)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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? %>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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' %>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants