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
12 changes: 12 additions & 0 deletions app/controllers/api/v8/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ class BaseController < ApplicationController
end
end

rescue_from CoursesMoocFiTokenIntrospector::Unavailable do |e|
Rails.logger.error("courses.mooc.fi token introspection unavailable: #{e.message}")
respond_with_error('courses.mooc.fi could not verify your login right now. Try again later.', 503)
end

rescue_from ActiveRecord::RecordNotFound do |e|
render json: errors_json(e.message), status: :not_found
end
Expand All @@ -40,12 +45,19 @@ def authenticate_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
@current_user = CoursesMoocFiAuthentication.user_for(request)
@auth_source = :courses_mooc_fi_token if @current_user
end
@current_user ||= user_from_session || Guest.new
end

attr_reader :current_user

def current_ability
@current_ability ||= ::Ability.new(current_user, auth_source: @auth_source)
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
8 changes: 4 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'
parameter do
key :name, :courses_mooc_fi_user_id
key :in, :formData
Expand All @@ -84,18 +84,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 Expand Up @@ -249,6 +248,7 @@ def set_password_managed_by_courses_mooc_fi
end

user = User.find_by!(id: params[:id])
authorize! :update, user
User.transaction do
user.password_managed_by_courses_mooc_fi = true
user.password_hash = nil
Expand Down
10 changes: 9 additions & 1 deletion app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
class Ability
include CanCan::Ability

def initialize(user)
# +auth_source+ is :courses_mooc_fi_token when the user authenticated with a courses.mooc.fi
# access token; account changes are then denied to everyone, administrators included.
def initialize(user, auth_source: nil)
if user.administrator?
can :manage, :all
can :create, Course
Expand Down Expand Up @@ -285,5 +287,11 @@ def initialize(user)
can?(:teach, o)
end
end

return unless auth_source == :courses_mooc_fi_token

# The token is scoped to exercise services; it must not take over or delete the account.
cannot :update, User
cannot :destroy, User
end
end
19 changes: 13 additions & 6 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 @@ -46,13 +48,18 @@ class User < ApplicationRecord
message: 'does not look like an email'
}

COURSES_MOOC_FI_USER_ID_FORMAT = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/

# Lookups by courses.mooc.fi id (token authentication) match exactly, so store the canonical form.
normalizes :courses_mooc_fi_user_id, with: ->(id) { id.strip.downcase }

# Guard the courses.mooc.fi delegation id: it must be a valid UUID and unique. A malformed id
# set here would otherwise be persisted while the local password hash is nulled, locking the
# user out (they could neither log in locally nor be delegated to courses.mooc.fi).
validates :courses_mooc_fi_user_id,
uniqueness: true,
format: {
with: /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/,
with: COURSES_MOOC_FI_USER_ID_FORMAT,
message: 'must be a valid UUID'
},
allow_blank: true
Expand Down Expand Up @@ -215,7 +222,7 @@ def courses_mooc_fi_authentication_status(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 @@ -269,7 +276,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 @@ -324,7 +331,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 Expand Up @@ -383,7 +390,7 @@ def force_migrate_to_courses_mooc_fi

response = conn.get(courses_mooc_fi_url("/api/v0/tmc-server/users-by-upstream-id/#{id}")) do |req|
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
end

data = response.body
Expand Down Expand Up @@ -422,7 +429,7 @@ def courses_mooc_fi_migration_status

response = conn.get(courses_mooc_fi_url("/api/v0/tmc-server/users-by-upstream-id/#{id}/status")) do |req|
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
end

data = response.body
Expand Down
40 changes: 40 additions & 0 deletions app/services/courses_mooc_fi_authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

# Resolves the local user behind a courses.mooc.fi access token sent to API v8.
module CoursesMoocFiAuthentication
# Returns the user the request's bearer token belongs to, or nil when there is no bearer token,
# courses.mooc.fi rejects it, or it belongs to no local user. Raises
# CoursesMoocFiTokenIntrospector::Unavailable when the token cannot be checked right now.
def self.user_for(request)
token = Doorkeeper::OAuth::Token.from_bearer_authorization(request)
return nil if token.blank?

result = CoursesMoocFiTokenIntrospector.introspect(token)
return nil unless result

User.find_by(courses_mooc_fi_user_id: result.sub) || link_by_upstream_id(result)
end

# Binds the user with the TMC id courses.mooc.fi reports to the token's subject, unless that user
# is already bound to another subject.
def self.link_by_upstream_id(result)
return nil if result.upstream_id.blank?

user = User.find_by(id: result.upstream_id)
return nil unless user
return user if user.courses_mooc_fi_user_id&.casecmp?(result.sub)

if user.courses_mooc_fi_user_id.present?
Rails.logger.warn("courses.mooc.fi upstream_id #{result.upstream_id} maps to user #{user.id}, which is bound to a different courses_mooc_fi_user_id; refusing")
return nil
end

# Skips validation; the introspector has already checked that sub is a UUID.
user.update_column(:courses_mooc_fi_user_id, result.sub)
user
rescue ActiveRecord::RecordNotUnique
# A concurrent request bound the subject first.
User.find_by(courses_mooc_fi_user_id: result.sub)
end
private_class_method :link_by_upstream_id
end
Loading
Loading