Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,51 @@ service postgresql restart
```
after to implement changes

#### Alternative: run PostgreSQL in a container

The committed `config/database.yml` expects a local PostgreSQL with a `tmc` superuser
reachable over the Unix socket, which is how the project and CI run — do not change those
defaults. If you only need to run the test suite and would rather not create that role or
edit `pg_hba.conf` on your machine (for example on a shared host where you are not root),
run a throwaway PostgreSQL in Docker instead and point just the test environment at it.

`config/database.yml` ends by ERB-including `config/database.local.yml` if it exists, so
that file can override any environment. It is gitignored — keep it that way, it is a
machine-local override and must never be committed.

Start the container (the version should match production; 14 at the time of writing):

```bash
docker run -d --name tmc-test-pg -p 127.0.0.1:5433:5432 \
-e POSTGRES_USER=tmc -e POSTGRES_PASSWORD=tmc -e POSTGRES_DB=tmc-test postgres:14
```

Create `config/database.local.yml`:

```yaml
test:
adapter: postgresql
username: tmc
password: tmc
database: tmc-test
host: 127.0.0.1
port: 5433
pool: 25
```

Load the schema and run specs:

```bash
RAILS_ENV=test bundle exec rake db:schema:load
RAILS_ENV=test bundle exec rspec spec/services
```

A non-default port (5433 above) keeps the container from colliding with a system
PostgreSQL on 5432. Note this covers the test environment only — the sandbox-backed
integration specs and the dev server still want the full local setup described above.
Remove the container with `docker rm -f tmc-test-pg` and delete
`config/database.local.yml` when you are done.

### TMC-server installation
#### Clone the TMC repository

Expand Down
6 changes: 3 additions & 3 deletions app/controllers/api/v8/apidocs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ class ApidocsController < ActionController::Base
key :required, true
key :type, :integer
end
parameter :path_user_email do
key :name, :user_email
key :in, :path
parameter :query_user_email do
key :name, :email
key :in, :query
key :description, "User's email"
key :required, true
key :type, :string
Expand Down
95 changes: 95 additions & 0 deletions app/controllers/api/v8/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,113 @@ def present(hash)
end
end

# The exact UUID format the User model enforces on courses_mooc_fi_user_id (see
# app/models/user.rb). Duplicated here so the introspected subject is shape-checked before
# any lookup or the update_column backfill, which bypasses that load-bearing model validation.
COURSES_MOOC_FI_USER_ID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/

private
def authenticate_user!
return @current_user if @current_user
if doorkeeper_token
@current_user ||= User.find_by(id: doorkeeper_token.resource_owner_id)
raise 'Invalid token' unless @current_user
elsif Rails.configuration.x.accept_courses_mooc_fi_tokens && (bearer = bearer_token).present?
@current_user ||= user_from_courses_mooc_fi_token(bearer)
end
@current_user ||= user_from_session || Guest.new
end

attr_reader :current_user

# Additive, feature-flagged auth path (Rails.configuration.x.accept_courses_mooc_fi_tokens,
# default off). Only reached when there is no native Doorkeeper token. Treats the bearer as
# a courses.mooc.fi (secret-project-331) OAuth token, validates it via RFC 7662
# introspection, and maps it to a local user. Fails closed to nil (caller resolves Guest)
# on any problem; never raises.
def user_from_courses_mooc_fi_token(token)
result = CoursesMoocFiTokenIntrospector.introspect(token)
return nil unless result

unless result.scope?('exercise-services')
Rails.logger.warn('courses.mooc.fi token rejected: missing exercise-services scope')
return nil
end

# The subject is about to be used as courses_mooc_fi_user_id, both for the find_by below
# and (on a cache miss) for the update_column backfill, which bypasses the model's
# load-bearing UUID-format validation. Shape-check it once here so a malformed subject can
# neither be looked up nor persisted. Fail closed on mismatch.
unless COURSES_MOOC_FI_USER_ID_FORMAT.match?(result.sub)
Rails.logger.warn('courses.mooc.fi token rejected: subject is not a valid UUID')
return nil
end

user = User.find_by(courses_mooc_fi_user_id: result.sub)
user ||= backfill_from_upstream_id(result)
return nil unless user

# Decision 5 (widened 2026-07-23): introspected tokens must never resolve to an elevated
# user. Originally admins-only; now also blocks anyone holding any teachership or
# assistantship, because those grant real CanCan abilities (manage exercises/deadlines,
# read others' submissions). Elevated users keep using native tmc tokens; fail closed to
# Guest here.
reason = elevated_user_reason(user)
if reason
Rails.logger.warn("courses.mooc.fi token resolved to #{reason} user #{user.id}; refusing introspected auth (elevated users must use native tmc tokens)")
return nil
end

user
rescue => e
Rails.logger.warn("courses.mooc.fi token authentication error: #{e.class}: #{e.message}")
nil
end

# nil when the user holds no elevated role; otherwise a short reason naming the highest
# concern (administrator > teacher > assistant), used only for the warn log. Uses efficient
# existence checks rather than loading and iterating every organization/course.
def elevated_user_reason(user)
return 'administrator' if user.administrator?
return 'teacher' if Teachership.exists?(user_id: user.id)
return 'assistant' if Assistantship.exists?(user_id: user.id)
nil
end

# No user is mapped to this token's subject yet, but the introspection response carries the
# TMC integer id (upstream_id). Look the user up by it and backfill the UUID so later
# requests resolve directly. Guarded against the unique-index race and against clobbering a
# user already bound to a different subject.
def backfill_from_upstream_id(result)
upstream_id = result.upstream_id
return nil if upstream_id.blank?

user = User.find_by(id: upstream_id)
return nil unless user

if user.courses_mooc_fi_user_id.blank?
begin
user.update_column(:courses_mooc_fi_user_id, result.sub)
rescue ActiveRecord::RecordNotUnique
# Another request backfilled the same subject first. Trust the authoritative mapping.
user = User.find_by(courses_mooc_fi_user_id: result.sub)
end
elsif user.courses_mooc_fi_user_id != result.sub
# upstream_id points at a user already bound to a different subject. Do not override.
Rails.logger.warn("courses.mooc.fi upstream_id #{upstream_id} maps to user #{user.id} already bound to a different courses_mooc_fi_user_id; refusing")
return nil
end

user
end

def bearer_token
auth = request.authorization
return nil unless auth
match = auth.match(/\ABearer[ ]+(.+)\z/i)
match && match[1]
end

def errors_json(messages)
{ errors: [*messages] }
end
Expand Down
12 changes: 5 additions & 7 deletions app/controllers/api/v8/core/exercises/details_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,12 @@ class DetailsController < Api::V8::BaseController
parameter do
key :in, 'query'
key :name, 'ids'
schema do
key :type, :array
items do
key :type, :integer
end
end
key :type, :array
key :description, 'Exercise Ids'
key :type, :array
key :collectionFormat, 'csv'
items do
key :type, :integer
end
end
response 200 do
key :description, 'Exercises in json'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module Users
class SubmissionsController < Api::V8::BaseController
include Swagger::Blocks

swagger_path 'api/v8/exercises/{exercise_id}/users/{user_id}/submissions' do
swagger_path '/api/v8/exercises/{exercise_id}/users/{user_id}/submissions' do
operation :get do
key :description, 'Returns the submissions visible to the user in a json format'
key :operationId, 'findUsersSubmissionsForExerciseById'
Expand All @@ -33,7 +33,7 @@ class SubmissionsController < Api::V8::BaseController
end
end

swagger_path 'api/v8/exercises/{exercise_id}/users/current/submissions' do
swagger_path '/api/v8/exercises/{exercise_id}/users/current/submissions' do
operation :get do
key :description, "Returns the current user's submissions for the exercise in a json format. The exercise is searched by id."
key :operationId, 'findUsersOwnSubmissionsForExerciseById'
Expand Down
7 changes: 3 additions & 4 deletions app/controllers/api/v8/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ class UsersController < Api::V8::BaseController
key :operationId, 'setPasswordManagedByCoursesMoocFi'
key :produces, ['application/json']
key :tags, ['user']
parameter '$ref': '#/parameters/user_id'
parameter '$ref': '#/parameters/path_user_id'
response 403, '$ref': '#/responses/error'
response 404, '$ref': '#/responses/error'
response 200 do
Expand All @@ -76,18 +76,17 @@ class UsersController < Api::V8::BaseController
end
end

swagger_path '/api/v8/users/get_user_with_email?email={email}' do
swagger_path '/api/v8/users/get_user_with_email' do
operation :get do
key :description, "Returns the user's id as upstream_id, user's courses.mooc.fi-id as id, email, first name and last name by user email"
key :operationId, 'getUserInformationByEmail'
key :produces, ['application/json']
key :tags, ['user']
parameter '$ref': '#/parameters/user_email'
parameter '$ref': '#/parameters/query_user_email'
response 403, '$ref': '#/responses/error'
response 404, '$ref': '#/responses/error'
response 200 do
key :description, "User's courses.mooc.fi-id as id, email, first name, last name and id as upstream_id as json"
key :content, 'application/json'
schema do
key :title, :user
key :required, [:user]
Expand Down
8 changes: 5 additions & 3 deletions app/models/user.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require 'app_secrets'

class User < ApplicationRecord
include Comparable
include Gravtastic
Expand Down Expand Up @@ -206,7 +208,7 @@ def authenticate_via_courses_mooc_fi(submitted_password)
response = conn.post(auth_url) do |req|
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project
req.headers['Authorization'] = AppSecrets.tmc_server_secret_for_communicating_to_secret_project

req.body = {
user_id: courses_mooc_fi_user_id,
Expand Down Expand Up @@ -256,7 +258,7 @@ def update_password_via_courses_mooc_fi(old_password, new_password)
response = conn.post(update_url) do |req|
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project
req.headers['Authorization'] = AppSecrets.tmc_server_secret_for_communicating_to_secret_project

req.body = {
user_id: self.courses_mooc_fi_user_id,
Expand Down Expand Up @@ -314,7 +316,7 @@ def post_new_user_to_courses_mooc_fi(password)
response = conn.post(create_url) do |req|
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project
req.headers['Authorization'] = AppSecrets.tmc_server_secret_for_communicating_to_secret_project

req.body = {
upstream_id: id,
Expand Down
Loading
Loading