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
524 changes: 524 additions & 0 deletions .claude/plans/PLAN.3-user-accounts.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ gem 'sequel', '~>5.55'
gem 'table_print', '~>1.0' # Console / REPL formatting (dev only)

group :development, :test do
gem 'sequel-seed', '~>1.1'
gem 'sqlite3', '~>2.0'
end

Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ GEM
ruby-progressbar (1.13.0)
sequel (5.103.0)
bigdecimal
sequel-seed (1.1.2)
sequel (>= 4.49.0)
sqlite3 (2.9.3-aarch64-linux-gnu)
sqlite3 (2.9.3-aarch64-linux-musl)
sqlite3 (2.9.3-arm-linux-gnu)
Expand Down Expand Up @@ -146,6 +148,7 @@ DEPENDENCIES
rubocop-rake
rubocop-sequel
sequel (~> 5.55)
sequel-seed (~> 1.1)
sqlite3 (~> 2.0)
table_print (~> 1.0)

Expand Down Expand Up @@ -202,6 +205,7 @@ CHECKSUMS
rubocop-sequel (0.4.1) sha256=f325dc470c1e3191a616b41a4bf8cfbb2c3c2f4fbc2eee6286de019ca1f7c113
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
sequel (5.103.0) sha256=51bf23374cc585724fc51a2ae8b95283422c97ca4585ce28f3db27a26ce55a69
sequel-seed (1.1.2) sha256=5639451aa0f50620a731c0b8a8859a2404c2feaa4f359778483b32ef2a5a2601
sqlite3 (2.9.3-aarch64-linux-gnu) sha256=ca6dd1cf6c037ccc8d3e5837190cc61ef15466092014951235641b5c4c8ab4ee
sqlite3 (2.9.3-aarch64-linux-musl) sha256=ff017a36c463d02e9f0be7a6224521371128024e6a05ed16994afa5c037afbba
sqlite3 (2.9.3-arm-linux-gnu) sha256=fd8b74337a66bdaf746b97d65e6c9a2faff803c8f72d6b107fb880972815d072
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ API to manage courses, events, locations, and attendance tracking.
All routes return JSON.

- GET `/`: Root route shows if Web API is running
- GET `api/v1/accounts/[username]`: Get a single account
- POST `api/v1/accounts`: Create a new account
- GET `api/v1/courses`: Get list of all courses
- POST `api/v1/courses`: Create a new course
- GET `api/v1/courses/[course_id]`: Get a single course
Expand Down Expand Up @@ -34,6 +36,15 @@ Setup development database once:
rake db:migrate
```

Optionally populate the development database with sample accounts, courses,
enrollments, locations, and events:

```shell
rake db:seed
# or, to wipe and reseed from scratch:
rake reseed
```

## Execute

Run this API using:
Expand Down Expand Up @@ -64,3 +75,9 @@ audits pass:
```shell
rake release_check
```

## For Contributors

- **Database schema** — see [`docs/schema.md`](docs/schema.md) for the
entity-relationship diagram and the rationale behind encrypted columns,
keyed-hash lookup, role enumeration, and cascade behavior.
27 changes: 26 additions & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ namespace :db do
end

task :load_models do # rubocop:disable Rake/Desc
require_app(%w[config models])
require_app(%w[config models services])
end

desc 'Run migrations'
Expand All @@ -63,6 +63,7 @@ namespace :db do
task delete: :load_models do
Tyto::Event.dataset.destroy
Tyto::Location.dataset.destroy
Tyto::Account.dataset.destroy
Tyto::Course.dataset.destroy
end

Expand All @@ -77,12 +78,36 @@ namespace :db do
FileUtils.rm(db_filename)
puts "Deleted #{db_filename}"
end

task reset_seeds: :load_models do # rubocop:disable Rake/Desc
db = Tyto::Api.DB
db[:schema_seeds].delete if db.tables.include?(:schema_seeds)
Tyto::Account.dataset.destroy
Tyto::Course.dataset.destroy
end

desc 'Seeds the development database'
task seed: :load_models do
require 'sequel/extensions/seed'
Sequel::Seed.setup(:development)
Sequel.extension :seed
Sequel::Seeder.apply(Tyto::Api.DB, 'db/seeds')
end
end

desc 'Delete all data and reseed'
task reseed: %i[db:reset_seeds db:seed]

namespace :newkey do
desc 'Create sample cryptographic key for database'
task :db do
require_app('lib', config: false)
puts "DB_KEY: #{Tyto::SecureDB.generate_key}"
end

desc 'Create sample cryptographic key for HMAC lookup hashing'
task :hash do
require_app('lib', config: false)
puts "HASH_KEY: #{Tyto::SecureDB.generate_key}"
end
end
97 changes: 93 additions & 4 deletions app/controllers/app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ module Tyto
# Web controller for Tyto API
class Api < Roda # rubocop:disable Metrics/ClassLength
plugin :halt
plugin :all_verbs

route do |routing|
response['Content-Type'] = 'application/json'
Expand All @@ -18,6 +19,37 @@ class Api < Roda # rubocop:disable Metrics/ClassLength

@api_root = 'api/v1'
routing.on @api_root do
routing.on 'accounts' do
@account_route = "#{@api_root}/accounts"

routing.on String do |username|
# GET api/v1/accounts/[username]
routing.get do
account = Account.first(username:)
account ? account.to_json : raise('Account not found')
rescue StandardError => e
routing.halt 404, { message: e.message }.to_json
end
end

# POST api/v1/accounts
routing.post do
new_data = JSON.parse(routing.body.read)
new_account = Account.new(new_data)
raise('Could not save account') unless new_account.save_changes

response.status = 201
response['Location'] = "#{@account_route}/#{new_account.id}"
{ message: 'Account saved', data: new_account }.to_json
rescue Sequel::MassAssignmentRestriction
Api.logger.warn "MASS-ASSIGNMENT: #{new_data.keys}"
routing.halt 400, { message: 'Illegal Attributes' }.to_json
rescue StandardError => e
Api.logger.error "UNKNOWN ERROR: #{e.message}"
routing.halt 500, { message: 'Unknown server error' }.to_json
end
end

routing.on 'courses' do
@course_route = "#{@api_root}/courses"

Expand All @@ -44,8 +76,9 @@ class Api < Roda # rubocop:disable Metrics/ClassLength
# POST api/v1/courses/[course_id]/events
routing.post do
new_data = JSON.parse(routing.body.read)
course = Course.first(id: course_id)
new_event = course.add_event(new_data)
new_event = CreateEventForCourse.call(
course_id:, event_data: new_data
)
raise 'Could not save event' unless new_event

response.status = 201
Expand Down Expand Up @@ -82,8 +115,9 @@ class Api < Roda # rubocop:disable Metrics/ClassLength
# POST api/v1/courses/[course_id]/locations
routing.post do
new_data = JSON.parse(routing.body.read)
course = Course.first(id: course_id)
new_loc = course.add_location(new_data)
new_loc = CreateLocationForCourse.call(
course_id:, location_data: new_data
)
raise 'Could not save location' unless new_loc

response.status = 201
Expand All @@ -98,6 +132,61 @@ class Api < Roda # rubocop:disable Metrics/ClassLength
end
end

routing.on 'enrollments' do
@enrollment_route = "#{@api_root}/courses/#{course_id}/enrollments"

# DELETE api/v1/courses/[course_id]/enrollments/[enrollment_id]
routing.on String do |enrollment_id|
routing.delete do
enrollment = Enrollment.first(id: enrollment_id)
unless enrollment && enrollment.course_id.to_s == course_id.to_s
routing.halt 404, { message: 'Enrollment not found' }.to_json
end

enrollment.destroy
{ message: 'Enrollment removed' }.to_json
rescue StandardError => e
Api.logger.error "UNKNOWN ERROR: #{e.message}"
routing.halt 500, { message: 'Unknown server error' }.to_json
end
end

# GET api/v1/courses/[course_id]/enrollments
routing.get do
output = { data: Course.first(id: course_id).enrollments }
JSON.pretty_generate(output)
rescue StandardError
routing.halt 404, { message: 'Could not find enrollments' }.to_json
end

# POST api/v1/courses/[course_id]/enrollments
routing.post do
new_data = JSON.parse(routing.body.read)
account = Account.first(username: new_data['username'])
routing.halt(404, { message: 'Account not found' }.to_json) unless account

enrollment = EnrollAccountInCourse.call(
account_id: account.id, course_id:,
role_name: new_data['role_name']
)
raise 'Could not save enrollment' unless enrollment

response.status = 201
response['Location'] = "#{@enrollment_route}/#{enrollment.id}"
{ message: 'Enrollment created', data: enrollment }.to_json
rescue Tyto::EnrollAccountInCourse::UnknownRoleError
routing.halt 400, { message: 'Unknown role' }.to_json
rescue Sequel::UniqueConstraintViolation
routing.halt 409, { message: 'Enrollment already exists' }.to_json
rescue Sequel::MassAssignmentRestriction
Api.logger.warn "MASS-ASSIGNMENT: #{new_data.keys}"
routing.halt 400, { message: 'Illegal Attributes' }.to_json
rescue StandardError => e
Api.logger.error "UNKNOWN ERROR: #{e.message}"
routing.halt 500, { message: 'Unknown server error' }.to_json
end
end

# GET api/v1/courses/[course_id]
routing.get do
course = Course.first(id: course_id)
Expand Down
24 changes: 24 additions & 0 deletions app/lib/key_stretch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

require 'base64'
require 'rbnacl'

module Tyto
# Hashes password using key-stretching hash algorithm
module KeyStretch
def new_salt
RbNaCl::Random.random_bytes(RbNaCl::PasswordHash::SCrypt::SALTBYTES)
end

def password_hash(salt, password)
opslimit = 2**20
memlimit = 2**24
digest_size = 64

RbNaCl::PasswordHash.scrypt(
password, salt,
opslimit, memlimit, digest_size
)
end
end
end
17 changes: 14 additions & 3 deletions app/lib/secure_db.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@ module Tyto
# Encrypt and Decrypt from Database
class SecureDB
class NoDbKeyError < StandardError; end
class NoHashKeyError < StandardError; end

# Generate key for Rake tasks (typically not called at runtime)
def self.generate_key
key = RbNaCl::Random.random_bytes(RbNaCl::SecretBox.key_bytes)
Base64.strict_encode64 key
end

def self.setup(base_key)
raise NoDbKeyError unless base_key
def self.setup(db_key, hash_key)
raise NoDbKeyError unless db_key
raise NoHashKeyError unless hash_key

@key = Base64.strict_decode64(base_key)
@key = Base64.strict_decode64(db_key)
@hash_key = Base64.strict_decode64(hash_key)
end

# Encrypt or else return nil if data is nil
Expand All @@ -37,5 +40,13 @@ def self.decrypt(ciphertext64)
simple_box = RbNaCl::SimpleBox.from_secret_key(@key)
simple_box.decrypt(ciphertext).force_encoding(Encoding::UTF_8)
end

# Keyed hash for deterministic lookup on encrypted columns
def self.hash(plaintext)
return nil unless plaintext

digest = RbNaCl::HMAC::SHA256.auth(@hash_key, plaintext)
Base64.strict_encode64(digest)
end
end
end
62 changes: 62 additions & 0 deletions app/models/account.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# frozen_string_literal: true

require 'sequel'
require 'json'
require_relative 'password'

module Tyto
# Models a registered account
class Account < Sequel::Model
one_to_many :enrollments
many_to_many :system_roles,
class: :'Tyto::Role',
join_table: :accounts_roles,
left_key: :account_id,
right_key: :role_id
many_to_many :courses, join_table: :enrollments

# :nullify on a many_to_many removes the join-table rows (not the
# associated courses) — one bulk DELETE, keeps courses intact.
plugin :association_dependencies, courses: :nullify

plugin :whitelist_security
set_allowed_columns :username, :email, :password, :avatar

plugin :timestamps, update_on_create: true

# Email is PII: store encrypted ciphertext + HMAC lookup hash.
def email
SecureDB.decrypt(email_secure)
end

def email=(plaintext)
self.email_secure = SecureDB.encrypt(plaintext)
self.email_hash = SecureDB.hash(plaintext)
end

def password=(new_password)
self.password_digest = Password.digest(new_password).to_s
end

def password?(try_password)
digest = Password.from_digest(password_digest)
digest.correct?(try_password)
end

def owned_courses
owner_role = Role.first(name: 'owner')
enrollments_dataset.where(role_id: owner_role.id).map(&:course)
end

def to_json(options = {})
JSON(
{
type: 'account',
id:,
username:,
email:
}, options
)
end
end
end
Loading