diff --git a/.claude/plans/PLAN.3-user-accounts.md b/.claude/plans/PLAN.3-user-accounts.md new file mode 100644 index 0000000..e35528e --- /dev/null +++ b/.claude/plans/PLAN.3-user-accounts.md @@ -0,0 +1,524 @@ +# 3-user-accounts — Account model with secure credentials, role join tables, enrollments + +> **IMPORTANT**: This plan must be kept up-to-date at all times. Assume context can be cleared at any time — this file is the single source of truth for the current state of this work. Update this plan before and after task and subtask implementations. + +## Branch + +`3-user-accounts` + +## Goal + +Introduce **Accounts** as a first-class persisted resource. This branch adds the `accounts` table, an `Account` Sequel model with secure password digestion (SCrypt via a `KeyStretch` library), and an HMAC-keyed email-lookup column so encrypted emails can still be queried. It also lays down the role-association data model: a `roles` reference table joined to accounts via `account_roles` (system-level roles: admin / creator / member), and an `enrollments` table joining accounts to courses with a `role_id` for the four course-level roles (owner / instructor / staff / student). + +Course ownership is **derived** from enrollments — `Course#owner` returns the account whose enrollment for the course has role `owner`. There is no `owner_id` foreign key on the courses table. + +`CreateCourseForOwner` is a transactional service that creates the course and its owner enrollment atomically. Two further service objects (`CreateEventForCourse`, `CreateLocationForCourse`) replace inline `course.add_event` / `course.add_location` calls in the controller so the service layer becomes the single seam for future policy enforcement. + +Account routes (`POST api/v1/accounts`, `GET api/v1/accounts/[username]`) and enrollment-management routes (`GET`/`POST` on `/api/v1/courses/[course_id]/enrollments`, `DELETE` on `/api/v1/courses/[course_id]/enrollments/[enrollment_id]`) ship this week. The enrollment routes are intentionally **ungated** — consistent with the courses / events / locations routes that also trust the client at this stage. Authentication of credentials at the HTTP layer and the role-based policy gates that decide **who** may POST/DELETE enrollments are deferred to later branches. + +## Strategy: Vertical Slice + +1. Extend `SecureDB` with a keyed-hash method (HMAC-SHA256 using a separate `HASH_KEY`); add `KeyStretch` for SCrypt password hashing. +2. Renumber existing migrations (courses → 002, locations → 003, events → 004) and add new migrations (accounts → 001, roles → 005, account_roles → 006, enrollments → 007). The pre-production schema-rebuild posture (master plan §Decisions #10) makes this clean: dev/test SQLite databases are dropped and re-migrated; no `alter_table` migrations needed pre-`5-deployable`. +3. Build `Account`, `Password`, `Role`, `Enrollment` models. Add `Course#owner`, `Course#instructors`, `Course#staff`, `Course#students` convenience accessors over `enrollments` (no role-blind `#members` — every call site is role-aware). +4. Introduce service objects: `CreateCourseForOwner` (transactional — course + owner enrollment), `CreateEventForCourse`, `CreateLocationForCourse`. +5. Add account routes; refactor course/event/location POST handlers to call the new services. +6. Unit specs for `Password` (Mandarin UTF-8 round-trip included from the start — the `force_encoding` fix already landed in the prior database-hardening branch); integration specs for account routes; service-integration spec for `CreateCourseForOwner` (commit 2). + +## Current State + +- [x] Plan created +- [x] Branch `3-user-accounts` created off `main` +- [x] `CLAUDE.local.md` repointed at this plan +- [x] Plan file committed (`docs: plan 3-user-accounts`, `c830b52`) +- [ ] **Commit 1** — `Adds Accounts with credentials to DB, models, and routes` (Mandarin UTF-8 regression test included from the start — the `force_encoding` fix already landed in the prior database-hardening branch, so no separate fix commit to mirror) +- [ ] **Commit 2** — `Add service integration test` +- [ ] `bundle exec rake spec` green +- [ ] `bundle exec rubocop .` green +- [ ] `bundle exec bundle audit check --update` green +- [ ] Code review +- [ ] Retrospective migration audit (diff-level + full-tree + shared-file content diff) +- [ ] Merge PR to `main` + +## Key Findings + +### Starting point + +- Domain on disk: `Course`, `Event`, `Location`. No `Account`, no `Role`, no `Enrollment`. +- `app/lib/secure_db.rb` already has `setup(base_key)`, `encrypt`, `decrypt` (with `.force_encoding(Encoding::UTF_8)` already applied in `2-db-hardening`). Needs to grow `hash(plaintext)` using `RbNaCl::HMAC::SHA256.auth` plus a separate key. +- `config/environments.rb` reads `DB_KEY` via `ENV.delete('DB_KEY')`. A sibling `ENV.delete('HASH_KEY')` is needed; `SecureDB.setup` will take both keys. +- `config/secrets-example.yml` and the `Rakefile`'s `newkey:db` task need siblings for `HASH_KEY` / `newkey:hash`. +- `require_app.rb` autoloads `%w[lib models controllers]`. Needs `services` added. +- `spec/spec_helper.rb`'s `wipe_database` resets events/locations/courses; needs to also clear `enrollments`, `account_roles`, `accounts` (children-first). +- `spec/env_spec.rb` (regression test that secrets are deleted from `ENV` after Figaro loads them) needs `HASH_KEY` added. +- Migrations on disk: `001_courses_create.rb`, `002_locations_create.rb`, `003_events_create.rb`. These will be renumbered (use `git mv` to keep `git log --follow` working). + +### Threat model delta + +| Risk | Addressed here | Deferred per project rules | +|---|---|---| +| Plaintext password storage | ✅ SCrypt-hashed via `KeyStretch` and a `Password` value object | Argon2 / future hash migration | +| GPU/ASIC password cracking at scale | ✅ SCrypt sequential-memory-hardness (`opslimit=2**20`, `memlimit=2**24`, `digest_size=64`) | Pepper | +| Email harvesting from a stolen DB dump | ✅ `email_secure` (SimpleBox encrypted, non-deterministic) | Email re-encryption rotation | +| No way to look up accounts by encrypted email | ✅ `email_hash` (HMAC-SHA256 with separate `HASH_KEY`) | Case/normalization on hash input | +| Single-key compromise exposes both confidentiality and lookup | ✅ Separate `HASH_KEY` and `DB_KEY` | Key rotation policy | +| Mass-assignment of internal account columns | ✅ `whitelist_security` allows only `:username, :email, :password, :avatar` | — | +| Password leakage through API serialization | ✅ `Account#to_json` returns `{type, id, username, email}` only | — | +| Mass-assignment of `Enrollment` rows (e.g., self-promotion student → owner) | ✅ `Enrollment` has `whitelist_security` allowing only `:account_id, :course_id, :role_id`; the enrollment POST route extracts `{username, role_name}` and calls `EnrollAccountInCourse` rather than mass-assigning directly. | **Who** may call the enrollment routes (role-based policy gating) lands in a later branch; this week any client can POST/DELETE, consistent with the other resource routes. | +| Course-creation race (course exists but no owner enrollment) | ✅ `CreateCourseForOwner` wraps both inserts in a Sequel transaction; spec covers rollback | — | +| Denormalized ownership (Credence's `owner_id` FK + collaborators join could disagree) | ✅ Single `enrollments` table is the only source of truth | — | +| Authentication of credentials at the HTTP layer | ❌ Deferred | — | +| Authorization (who can read/modify a course or its enrollments) | ⚠️ Partial — the **data model** lands here; the **enforcement** (Policy objects, route gates) is deferred | — | +| Server log leakage of passwords | Controllers log only `keys` of mass-assigned data; Roda's default logger does not log request bodies. Verbal lecture point: never `puts new_data` after `JSON.parse`. | Structured log redaction | + +### Domain scope (this branch) + +**New entities:** + +- `Account` — `id`, `username` (unique), `email_secure`, `email_hash` (unique), `password_digest`, `avatar`, timestamps. Provides `email=` (sets both `email_secure` via SimpleBox and `email_hash` via HMAC), `email` (decrypts `email_secure`), `password=` (sets `password_digest` via `Password.digest(...).to_s`), `password?(try)`. Whitelist allows only `:username, :email, :password, :avatar`. Associations: `one_to_many :enrollments`, `many_to_many :system_roles, ..., :join_table: :account_roles`, `many_to_many :courses, join_table: :enrollments`. Convenience: `#owned_courses` (enrollments where role is `owner`). +- `Password` — value object with `(salt, digest)`. `Password.digest(plaintext) → Password`, `Password.from_digest(json_str) → Password`, `#correct?(try)`. `#to_s` is JSON `{salt, hash}`. +- `Role` — `id`, `name`. Static reference. Seeded with all seven role names. `many_to_many :accounts, join_table: :account_roles`; `one_to_many :enrollments`. +- `Enrollment` — `id`, `account_id` (FK, NOT NULL), `course_id` (FK, NOT NULL), `role_id` (FK, NOT NULL), timestamps. **Unique constraint at the DB level on `[account_id, course_id, role_id]`** so duplicate triples cannot be inserted by any code path (services, seeds, raw SQL, future routes). `many_to_one :account`, `many_to_one :course`, `many_to_one :role`. No `whitelist_security` — only services and seeds create rows. No spec asserts these DB invariants — that's the point of putting them in the schema. Behavioral tests for multi-role / duplicate prevention / ownerless-course belong in a later branch where policy code makes those invariants observable. + +**Existing entities, modified:** + +- `Course` — gains `one_to_many :enrollments`, `plugin :association_dependencies, enrollments: :delete` (alongside existing `events: :destroy, locations: :destroy`). **No `many_to_many :members`** — role-specific scopes carry all role-aware access; a role-blind members list has no caller. `enrollments: :delete` on course destruction is a 1-query bulk DELETE (no per-row `.destroy`; `Enrollment` has no hooks). Convenience scopes: `Course#owner` returns the account whose enrollment has role `owner`; `Course#instructors`, `Course#staff`, `Course#students` return arrays of accounts. **No `owner_id` column** — owner is derived. + +**New library modules:** + +- `KeyStretch` (mixin) — `new_salt`, `password_hash(salt, password)`. Used by `Password.digest`. + +**Extended library modules:** + +- `SecureDB` — gains `self.hash(plaintext)` returning Base64-encoded HMAC-SHA256 digest using `@hash_key`. `setup` now takes `(db_key, hash_key)`. New `NoHashKeyError`. Existing `encrypt`/`decrypt` unchanged. + +**New services:** + +- `CreateCourseForOwner.call(owner_id:, course_data:)` — transactional (`Tyto::Api.DB.transaction`): + 1. find the account; raise `UnknownOwnerError` if not found (caller-friendly error class) + 2. find the `owner` role (no custom error — if missing, the `Enrollment.create` below hits the DB NOT NULL on `role_id` and Sequel raises naturally; "use DB constraints where obvious") + 3. `Course.create(course_data)` + 4. `Enrollment.create(account_id:, course_id:, role_id:)` + 5. return the course + Atomic — any failure rolls back both inserts. +- `EnrollAccountInCourse.call(account_id:, course_id:, role_name:)` — looks up the role by name and creates an `Enrollment`. Single seam for all non-owner enrollments (instructor / staff / student). Seeds use it this branch; enrollment-management routes will reuse it in `7-policies` when policy enforcement lands. +- `CreateEventForCourse.call(course_id:, event_data:)` — `Course.first(id: course_id).add_event(event_data)` +- `CreateLocationForCourse.call(course_id:, location_data:)` — `Course.first(id: course_id).add_location(location_data)` + +**Migrations after this branch (renumbered + new):** + +```text +db/migrations/ +├── 001_accounts_create.rb NEW +├── 002_courses_create.rb renamed from 001 (no owner_id added) +├── 003_locations_create.rb renamed from 002 +├── 004_events_create.rb renamed from 003 +├── 005_roles_create.rb NEW +├── 006_account_roles_create.rb NEW (create_join_table) +└── 007_enrollments_create.rb NEW (account_id + course_id + role_id, unique on the triple) +``` + +The dev/test SQLite databases get blown away and re-migrated as part of this branch. This is allowed pre-`5-deployable` per master plan §Decisions #10 (schema evolution): until production deployment, migrations may freely renumber, rename, drop columns, or restructure tables. + +## Questions + +> Strike through with the resolved decision once answered. + +- [x] **Q1.** Add `owner_id` to courses now? → **No** (revised). Owner is derived from enrollments (`role = 'owner'`). Adding `owner_id` would duplicate the relationship and create a denormalization risk. +- [x] **Q2.** Add the per-course role join (`enrollments`) now? → **Yes** (revised). The lecture deck teaches the join-table pattern this week using a project↔collaborator example; `enrollments` is the right Tyto-shaped equivalent. +- [x] **Q3.** Seed `roles` with all seven names or only system-level three? → **All seven.** All seven are referenced this week — three by `account_roles`, four by `enrollments`. +- [x] **Q4.** Normalize email (lowercase/trim) before hashing? → **No**, deferred. Document as a deliberate design hole for the lecture. +- [x] **Q5.** Should `account.to_json` expose `email`? → **Yes**, but the integration spec must assert `salt`, `password`, `password_hash`, `email_secure`, and `email_hash` are absent. +- [x] **Q6.** `git mv` the renumbered migrations or rm+add? → **`git mv`** to preserve `git log --follow`. +- [x] **Q7.** Do we need `Course#owner` as a real method? → **Yes.** Tests, seeds, and (later) routes will call it. Implement once, in the model, with a Sequel dataset query against `enrollments`. Tested transitively via the service spec's HAPPY case (`course.owner == account`) — no separate unit test needed. +- [x] **Q8.** Does `Account` get an `add_owned_course` convenience method? → **No.** Without `one_to_many :owned_courses, key: :owner_id`, Sequel won't auto-generate it. The service object is the single seam. +- [x] **Q9.** Do we write specs for "duplicate triples are rejected" or "an account can hold multiple roles in the same course"? → **No** for this branch. These are DB invariants enforced by the unique constraint on `[account_id, course_id, role_id]` in the enrollments migration. The DB enforces; specs would just verify the constraint exists, which is framework-level testing. Behavioral tests of these invariants belong in the branch that adds policy logic with observable consequences. +- [x] **Q10.** Do we keep a custom error class for "owner role not found"? → **No.** If the `roles` seed is broken, `Role.first(name: 'owner')` returns nil and the subsequent `Enrollment.create(role_id: nil)` hits the DB NOT NULL constraint. The DB constraint *is* the enforcement; a custom exception is decoration that needs its own un-tested code path. +- [x] **Q12.** Ship enrollment-management routes this branch, or defer to the policy branch? → **Ship them this branch, intentionally ungated.** Rationale: + - Consistency with courses/events/locations, which also accept un-authenticated writes at this stage. + - Lets the future app exercise enrollments via HTTP without running seeds. + - Cleaner pedagogical story for the policy branch: the routes already exist; that lecture introduces *gates*, not both routes and gates simultaneously. Same before/after pattern as `2-demo-db-vulnerabilities` → `2-db-hardening`. + - `Enrollment` gains `whitelist_security` for defense-in-depth so any code path that directly mass-assigns to `Enrollment` can't set internal columns. + +- [x] **Q11.** Now that `SecureDB` owns two keys (`@key` for encryption + `@hash_key` for HMAC lookup), should `@key` be renamed to disambiguate? → **No.** Credence's `6-auth-token` (three branches out) extracts the whole `@key` + `generate_key` + `base_encrypt` / `base_decrypt` triad into a `Securable` mixin that both `SecureDB` and `AuthToken` extend; the ivar then lives behind the mixin's memoized `key` accessor and `SecureDB` stops owning it directly. Renaming now would double the refactor cost and solve a naming ambiguity that disappears structurally when the mixin lands. Candidates considered: `@sym_key` (misleading — HMAC key is also symmetric), `@db_key` (best mirror of the `DB_KEY` env var), `@enc_key` (describes purpose) — all moot given the deferred refactor. + +## Scope + +**In scope:** + +- `accounts` table with encrypted PII (`email_secure`, keyed-hash `email_hash`, `password_digest`) +- `roles` table seeded with all seven names + `account_roles` join (system-level) + `enrollments` join (per-course) +- `Account`, `Password`, `Role`, `Enrollment` models +- `Course#owner`, `Course#instructors`, `Course#staff`, `Course#students` convenience accessors over enrollments (no role-blind `#members` — every call site is role-aware) +- `Account#courses`, `Account#owned_courses` convenience accessors +- `KeyStretch` lib + extended `SecureDB` (HMAC method, `HASH_KEY` env var, `newkey:hash` Rake task) +- Four services: `CreateCourseForOwner` (transactional — course + owner enrollment), `EnrollAccountInCourse` (single seam for non-owner enrollments; seeds use it this branch, routes added in `7-policies`), `CreateEventForCourse`, `CreateLocationForCourse` +- Master seed file (`db/seeds/20260423_create_all.rb`) + `accounts_seed.yml` + `enrollments_seed.yml` +- Account routes: `POST api/v1/accounts`, `GET api/v1/accounts/[username]` +- **Enrollment routes (ungated):** `GET api/v1/courses/[course_id]/enrollments` (list), `POST api/v1/courses/[course_id]/enrollments` (create, body `{username, role_name}`), `DELETE api/v1/courses/[course_id]/enrollments/[enrollment_id]` (remove; validates enrollment belongs to the named course). `whitelist_security` on `Enrollment` blocks mass-assignment of internal columns. Role-based access control is deferred to a later branch. +- `plugin :all_verbs` added to the Roda app so `routing.delete` resolves as a verb matcher (Roda 3 ships only `get`/`post` by default). +- `passwords_spec.rb` (unit; Mandarin round-trip included from commit 1 — the `force_encoding` fix already lives in `SecureDB.decrypt` from the prior database-hardening branch, so there's no separate UTF-8-fix commit to mirror) +- `api_accounts_spec.rb` (integration) +- `service_create_course_for_owner_spec.rb` (integration, commit 2 — happy + transactional rollback) +- **No `enrollments_spec.rb`** — DB constraints (unique triple, NOT NULL FKs) enforce structural invariants directly; behavioral tests of those invariants belong in a later branch. +- README updates listing new routes +- Refactor course/event/location POST handlers to call the new services + +**Out of scope** (deferred per project rules — do not creep in): + +- Authentication route +- Encrypted/expiring auth tokens +- Authorization policies / policy objects +- Role-based policy gating for the enrollment routes (the routes themselves ship this branch, ungated — who may call them is enforced later) +- Attendance (table, model, routes) +- Email verification +- SSO +- Signed requests +- Pepper for password hashing +- Avatar file upload (column is a free-form String) +- Email normalization (lower/trim) +- `whitelist_security` on `Role` — the roles table is seed-only and not user-creatable +- `enrollments_spec.rb` — DB constraints enforce the invariants; behavioral tests deferred +- `MissingOwnerRoleError` custom exception in `CreateCourseForOwner` — DB NOT NULL on `enrollments.role_id` raises naturally if the seed is broken +- Tests of "ownerless course" behavior, multi-role-per-course, or duplicate-triple rejection — deferred + +## Security Concerns Addressed This Week + +1. **Plaintext password storage is unsafe** — passwords are stored as SCrypt digests via `Password` + `KeyStretch`. The plaintext is never persisted, never logged, never serialized. Validated by `passwords_spec.rb` and by `api_accounts_spec.rb` asserting `result['password']`/`result['password_hash']`/`result['salt']` are all `nil`. +2. **Brute-force resistance** — SCrypt parameters (`opslimit=2**20`, `memlimit=2**24`, `digest_size=64`) defeat GPU/ASIC parallelism via sequential-memory-hardness. +3. **Per-account salt** — defeats rainbow tables; identical passwords produce different digests across accounts. +4. **Email is PII** — encrypted at rest via `SecureDB.encrypt` (RbNaCl SimpleBox, non-deterministic ciphertext). +5. **Encrypted-but-searchable** — keyed-hash (HMAC-SHA256, `RbNaCl::HMAC::SHA256.auth(@hash_key, plaintext)`) on a *separate* `HASH_KEY` enables `WHERE email_hash = ?` lookup without exposing plaintext or being brute-forceable from a leaked DB alone. +6. **Key separation** — `DB_KEY` (confidentiality) and `HASH_KEY` (lookup) are distinct. Compromise of one doesn't compromise the other. +7. **Mass-assignment defense extended to accounts** — `whitelist_security` allows only `:username, :email, :password, :avatar`. Internal columns cannot be set from JSON. Verified by `api_accounts_spec.rb`. +8. **No password leakage through API serialization** — `Account#to_json` returns `{type, id, username, email}` only. +9. **Service objects encapsulate side-effects** — `CreateCourseForOwner` is the future hook point for authorization checks. +10. **Server log discipline** — controllers log only `keys` of mass-assigned data, never values; passwords never reach `Api.logger`. +11. **Atomic course-ownership creation** — `CreateCourseForOwner` wraps the course insert and the owner-enrollment insert in a Sequel transaction. Without this, a database hiccup could leave a course with no owner — silently denying anyone the right to manage it once policy enforcement lands. Spec asserts no Course row remains after a forced rollback. +12. **Single source of truth for "who has what role on what course"** — the `enrollments` table is the only place this lives. Lecture moment: the security model is only as strong as its data model; when policies arrive in a future branch, every check reads from this one table. +13. **Database constraints as the authoritative enforcement layer** — `enrollments` has a unique constraint on `[account_id, course_id, role_id]` and NOT NULL on every FK. `accounts.username` and `accounts.email_hash` are unique. `account_roles` (via `create_join_table`) has a composite primary key blocking duplicates. We do **not** write specs to verify these constraints exist — that would just verify the framework. Application code can rely on them (no defensive double-checks needed in services), and broken invariants surface as Sequel exceptions at the boundary. Project rule: use DB constraints where obvious; the database is the source of truth for structural invariants. Application code and policy logic only enforce *contextual* rules (e.g., "this user is a student, so they can only check in to events"). + +## Tasks + +> Check tasks off as soon as each one is finished — do not batch. + +### Plan-phase scaffolding + +- [x] T01. Read reference branch + read `tyto2026-api/main` state +- [x] T02. Read week's lecture deck and synthesize themes +- [x] T03. Decide commit count (3) and grouping +- [x] T04. Write this plan +- [x] T05. `git checkout main`, then `git checkout -b 3-user-accounts` +- [x] T06. Repoint `CLAUDE.local.md` at this plan +- [x] T07. Commit only this plan: `git commit -m "docs: plan 3-user-accounts"` +### Commit 1 — `Adds Accounts with credentials to DB, models, and routes` + +#### Setup + +- [ ] T08. Add `gem 'sequel-seed'` to `Gemfile` (under `:development, :test`); `bundle install` +- [ ] T09. After T15: `bundle exec rake newkey:hash` and paste the value into `config/secrets.yml` for `development` and `test`. Never commit this file. + +#### Crypto / lib + +- [ ] T10. Create `app/lib/key_stretch.rb` (module `Tyto::KeyStretch`) +- [ ] T11. Extend `app/lib/secure_db.rb`: add `NoHashKeyError`, change `setup` to `setup(db_key, hash_key)`, store `@hash_key`, add `self.hash(plaintext)` returning `Base64.strict_encode64(RbNaCl::HMAC::SHA256.auth(@hash_key, plaintext))` +- [ ] T12. Extend `spec/unit/secure_db_spec.rb` with two tests: deterministic keyed hash for same input; different keyed hashes for different inputs + +#### Config + +- [ ] T13. Update `config/environments.rb`: `SecureDB.setup(ENV.delete('DB_KEY'), ENV.delete('HASH_KEY'))` +- [ ] T14. Update `config/secrets-example.yml`: add `HASH_KEY:` placeholder for `development`, `test`, `production` +- [ ] T15. Update `Rakefile`: add `newkey:hash` task (reuse `Tyto::SecureDB.generate_key`) +- [ ] T15a. Wire up seeding tasks in `Rakefile` per this week's lecture deck (slide 22 — students will run these): + - Extend existing `db:load_models` to `require_app(%w[config models services])` (adding `services`) so seeders can call `CreateCourseForOwner` and `EnrollAccountInCourse`. + - Add `task :reset_seeds => [:load_models]`: delete `@app.DB[:schema_seeds]` rows if the table exists; `Tyto::Account.dataset.destroy` (cascades via `association_dependencies`). + - Add `desc 'Seeds the development database'; task :seed => [:load_models]` that `require 'sequel/extensions/seed'`, calls `Sequel::Seed.setup(:development)`, `Sequel.extension :seed`, `Sequel::Seeder.apply(@app.DB, 'db/seeds')`. + - Add top-level `desc 'Delete all data and reseed'; task reseed: %i[db:reset_seeds db:seed]`. +- [ ] T16. Update `require_app.rb` default folders to `%w[lib models services controllers]` +- [ ] T17. Update `spec/env_spec.rb` to also assert `ENV['HASH_KEY']` is `nil` after boot + +#### Schema + +- [ ] T18. Create `db/migrations/001_accounts_create.rb` (`username` unique, `email_secure` non-null, `email_hash` non-null+unique, `password_digest`, `avatar`, timestamps) +- [ ] T19. `git mv db/migrations/001_courses_create.rb db/migrations/002_courses_create.rb` (**no body change** — no `owner_id`) +- [ ] T20. `git mv db/migrations/002_locations_create.rb db/migrations/003_locations_create.rb` +- [ ] T21. `git mv db/migrations/003_events_create.rb db/migrations/004_events_create.rb` +- [ ] T22. Create `db/migrations/005_roles_create.rb` (`primary_key :id`, `String :name, null: false, unique: true`, timestamps) +- [ ] T23. Create `db/migrations/006_account_roles_create.rb` using `create_join_table(account_id: :accounts, role_id: :roles)` +- [ ] T24. Create `db/migrations/007_enrollments_create.rb` — `primary_key :id`; `foreign_key :account_id, :accounts, null: false`; `foreign_key :course_id, :courses, null: false`; `foreign_key :role_id, :roles, null: false`; timestamps; `unique %i[account_id course_id role_id]` +- [ ] T25. Drop + re-migrate dev and test DBs (`bundle exec rake db:drop && bundle exec rake db:migrate`; same for `RACK_ENV=test`). Allowed pre-`5-deployable`. + +#### Models + +- [ ] T26. Create `app/models/password.rb` (value object, `extend KeyStretch`, namespaced `Tyto::Password`) +- [ ] T27. Create `app/models/account.rb`: + - `one_to_many :enrollments` + - `many_to_many :system_roles, class: :'Tyto::Role', join_table: :account_roles` + - `many_to_many :courses, join_table: :enrollments` + - `plugin :association_dependencies, courses: :nullify` — on a `many_to_many`, `:nullify` removes the join-table rows (1 bulk DELETE) and leaves the courses standing. Chosen over `enrollments: :destroy` on the `one_to_many` (which would be N per-row `.destroy` calls) for both performance and intent match ("remove the bridges, keep both endpoints") + - `plugin :whitelist_security; set_allowed_columns :username, :email, :password, :avatar` + - `plugin :timestamps, update_on_create: true` + - `email=`, `email`, `password=`, `password?(try)` + - `def owned_courses; enrollments_dataset.where(role: Tyto::Role.first(name: 'owner')).map(&:course); end` + - `to_json(opts) → {type, id, username, email}` +- [ ] T28. Create `app/models/role.rb` (`many_to_many :accounts, join_table: :account_roles`; `one_to_many :enrollments`; `to_json → {id, name}`) +- [ ] T29. Create `app/models/enrollment.rb` (`many_to_one :account`, `many_to_one :course`, `many_to_one :role`, timestamps; `to_json → {id, account_id, course_id, role: role.name}`) +- [ ] T30. Update `app/models/course.rb`: + - add `one_to_many :enrollments` + - **No** `many_to_many :members` — role-specific scopes cover every access site; a role-blind members list has no caller and adding it would let role-awareness sneak out of code paths + - extend `plugin :association_dependencies` to include `enrollments: :delete` (bulk 1-query DELETE, no per-row `.destroy`; fine because `Enrollment` has no hooks) + - add `Course#owner`, `Course#instructors`, `Course#staff`, `Course#students` convenience scopes + - **Do not** add `owner_id` to whitelist (the column does not exist) + +#### Services + +- [ ] T31. Create `app/services/create_course_for_owner.rb` — transactional: + + ```ruby + module Tyto + class CreateCourseForOwner + class UnknownOwnerError < StandardError; end + + def self.call(owner_id:, course_data:) + Tyto::Api.DB.transaction do + account = Account.first(id: owner_id) or raise UnknownOwnerError + owner_role = Role.first(name: 'owner') + course = Course.create(course_data) + Enrollment.create(account_id: account.id, course_id: course.id, role_id: owner_role&.id) + course + end + end + end + end + ``` + + Note: `owner_role&.id` deliberately produces `nil` if the `owner` role row is missing. The DB's NOT NULL on `enrollments.role_id` then raises and rolls back the transaction. The DB constraint is the enforcement; a custom exception class would be decoration. + +- [ ] T31a. Create `app/services/enroll_account_in_course.rb`: + + ```ruby + module Tyto + class EnrollAccountInCourse + class UnknownRoleError < StandardError; end + + def self.call(account_id:, course_id:, role_name:) + role = Role.first(name: role_name) or raise(UnknownRoleError, role_name) + Enrollment.create(account_id:, course_id:, role_id: role.id) + end + end + end + ``` + + Single seam for non-owner enrollments. Seeds call this for all instructor / staff / student rows; enrollment-management routes that call this service land in `7-policies` alongside policy checks. +- [ ] T32. Create `app/services/create_event_for_course.rb` — `Course.first(id: course_id).add_event(event_data)` +- [ ] T33. Create `app/services/create_location_for_course.rb` — `Course.first(id: course_id).add_location(location_data)` +- [ ] T34. Refactor `app/controllers/app.rb` POST handlers for events and locations to call the new services. Leave POST `/courses` calling `Course.new(...)` directly for now (no owner context yet — the route does NOT call `CreateCourseForOwner` because there's no authenticated account to use as owner; that wiring lands in the next branch). + +#### Controllers + +- [ ] T35. Add `routing.on 'accounts'` block (sibling to `routing.on 'courses'`): + - `routing.on String do |username| routing.get { Account.first(username:)... }` + - `routing.post do new_data = JSON.parse(routing.body.read); new_account = Account.new(new_data); raise unless new_account.save_changes; 201 + Location header end` + - `rescue Sequel::MassAssignmentRestriction → 400`, `rescue StandardError → 500` + - `Api.logger.warn "MASS-ASSIGNMENT: #{new_data.keys}"` (keys only, never values) +- [ ] T35a. Add `plugin :all_verbs` to the `Api` class so `routing.delete` works as a verb matcher (Roda 3 ships only `get`/`post` matchers by default). +- [ ] T35b. Add `plugin :whitelist_security; set_allowed_columns :account_id, :course_id, :role_id` to `app/models/enrollment.rb`. Defense-in-depth — the enrollment POST route doesn't mass-assign, but this hardens any future code path that does. +- [ ] T35c. Extend the controller under `routing.on 'courses' do ... routing.on String do |course_id|`: + - `routing.on 'enrollments' do` + - `routing.get` (no args) → `{ data: Course.first(id: course_id).enrollments }` + - `routing.post` → parse `{username, role_name}` from body; look up account by username (404 if unknown); call `EnrollAccountInCourse.call(account_id:, course_id:, role_name:)`; 201 + `Location` header pointing at `/api/v1/courses/:course_id/enrollments/:enrollment_id` + - `routing.on String do |enrollment_id|` → `routing.delete`: fetch `Enrollment.first(id: enrollment_id)`, assert its `course_id` matches the URL's `course_id` (404 if not — prevents cross-course ID guessing), `.destroy`, return 200 + `{message: 'Enrollment removed'}` + - Rescues: `Sequel::MassAssignmentRestriction` → 400 (+ `Api.logger.warn "MASS-ASSIGNMENT: #{new_data.keys}"`); `Tyto::EnrollAccountInCourse::UnknownRoleError` → 400; `Sequel::UniqueConstraintViolation` → 409; generic → 500 + +#### Seeds + +- [ ] T36. Create `db/seeds/accounts_seed.yml` with **nine accounts** (3 staff + 6 students): + + ```yaml + # Staff / faculty + - username: soumya.ray + email: soumya.ray@nthu.edu.tw + password: change_me_soumya + - username: jerry.ho + email: jerry.ho@nthu.edu.tw + password: change_me_jerry + - username: galit + email: galit@nthu.edu.tw + password: change_me_galit + # Students + - username: li.wei + email: li.wei@nthu.edu.tw + password: student_pass_1 + - username: chen.hsinyi + email: chen.hsinyi@nthu.edu.tw + password: student_pass_2 + - username: wang.ting + email: wang.ting@nthu.edu.tw + password: student_pass_3 + - username: lin.chiahao + email: lin.chiahao@nthu.edu.tw + password: student_pass_4 + - username: huang.peijun + email: huang.peijun@nthu.edu.tw + password: student_pass_5 + - username: tsai.yuting + email: tsai.yuting@nthu.edu.tw + password: student_pass_6 + ``` + + Passwords are placeholders; `Account.create` hashes them at seed time. + +- [ ] T36a. **Replace existing `db/seeds/course_seeds.yml`** with four canonical courses: + + ```yaml + - name: Service Oriented Architecture + description: Architectural patterns for loosely-coupled, service-centric systems. + - name: IT Service Security + description: Security engineering for internet-facing IT services. + - name: Computational Statistics + description: Simulation-based statistical methods and data analysis. + - name: Data Mining + description: Methods and practice for extracting patterns from data. + ``` + + Placeholder names from `1-db-orm` get overwritten (allowed pre-production). + +- [ ] T37. Create `db/seeds/enrollments_seed.yml` with explicit triples (13 rows): + + ```yaml + # Owners + - username: soumya.ray + course_name: Service Oriented Architecture + role_name: owner + - username: soumya.ray + course_name: IT Service Security + role_name: owner + - username: soumya.ray + course_name: Computational Statistics + role_name: owner + - username: galit + course_name: Data Mining + role_name: owner + # TA + - username: jerry.ho + course_name: IT Service Security + role_name: staff + # Students + - username: li.wei + course_name: Service Oriented Architecture + role_name: student + - username: li.wei + course_name: IT Service Security + role_name: student + - username: chen.hsinyi + course_name: IT Service Security + role_name: student + - username: chen.hsinyi + course_name: Data Mining + role_name: student + - username: wang.ting + course_name: Computational Statistics + role_name: student + - username: lin.chiahao + course_name: Service Oriented Architecture + role_name: student + - username: huang.peijun + course_name: Data Mining + role_name: student + - username: tsai.yuting + course_name: Computational Statistics + role_name: student + ``` + + Exercises `owner` (4 rows), `staff` (1 row), `student` (8 rows). Jerry.ho's only course enrollment is as TA (staff) of IT Service Security — she owns no course and instructs no course. `instructor` exists in the `roles` table but is not referenced by seeds this branch. + +- [ ] T38. Create `db/seeds/20260423_create_all.rb` master seeder using `Sequel.seed(:development)`: + 1. Seed `roles` (all seven names: `admin`, `creator`, `member`, `owner`, `instructor`, `staff`, `student`) via `Role.find_or_create(name:)` — idempotent. + 2. Seed accounts via `Account.create` for each row in `accounts_seed.yml`. + 3. Assign **system roles** hardcoded in the seeder: + - `soumya.ray` → `admin` + `creator` + - `jerry.ho` → `admin` + `creator` + - `galit` → `creator` (professor; empowered to create her own `Data Mining` course) + - `li.wei`, `chen.hsinyi`, `wang.ting`, `lin.chiahao`, `huang.peijun`, `tsai.yuting` → `member` + + Use `account.add_system_role(role)`. + 4. For each `enrollments_seed.yml` row where `role_name == 'owner'`, call `CreateCourseForOwner.call(owner_id:, course_data:)` (looking up course attributes from `course_seeds.yml` by `course_name`). + 5. For each non-owner row, call `EnrollAccountInCourse.call(account_id:, course_id:, role_name:)` — no direct `Enrollment.create` in seeds. + 6. Seed locations from `location_seeds.yml` into courses by course name via `CreateLocationForCourse`. + 7. Seed events from `event_seeds.yml` into courses by course name (cycle courses via `Enumerable#cycle`) via `CreateEventForCourse`. +- [ ] T39. Keep the existing per-resource YAMLs (`location_seeds.yml`, `event_seeds.yml`) as data sources — the master file orchestrates rather than replaces. + +#### Tests (commit 1) + +- [ ] T40. Create `spec/unit/passwords_spec.rb` — three tests (digest hides raw, correct password matches, wrong password doesn't) using Mandarin-containing password `'secret password of 雷松亞 stored in db'` per the week's lecture deck. Namespaced `Tyto::Password`. The reference branch's separate UTF-8-fix commit is skipped here because the `force_encoding` fix already landed in the prior database-hardening branch; the regression guarantee lives in this spec from day one. +- [ ] T41. **Skipped** — no `spec/unit/enrollments_spec.rb` this branch. DB constraints (unique `[account_id, course_id, role_id]`, NOT NULL FKs) enforce the structural invariants; behavioral tests of multi-role / duplicate prevention / ownerless-course belong in a later branch where policy code makes them observable. +- [ ] T42. Create `spec/integration/api_accounts_spec.rb`: + - `Account information / HAPPY: should be able to get details of a single account` (asserts no `salt`, `password`, `password_hash`, `email_secure`, `email_hash` in response) + - `Account Creation / HAPPY: should be able to create new accounts` (201, Location header, `account.password?` verifies) + - `Account Creation / BAD: should not create account with illegal attributes` (sends `created_at` → expects 400) +- [ ] T42a. Create `spec/integration/api_enrollments_spec.rb`: + - `Listing enrollments / HAPPY: should list enrollments for a course` + - `Creating enrollments / HAPPY: should create a student enrollment` (201, Location header, `course.students` includes the account) + - `Creating enrollments / SAD: should 404 on unknown username` + - `Creating enrollments / SAD: should 400 on unknown role_name` (Tyto::EnrollAccountInCourse::UnknownRoleError) + - `Creating enrollments / SAD: should 409 on duplicate (account, course, role) triple` (exercises the DB unique constraint surfacing as HTTP 409) + - `Deleting enrollments / HAPPY: should remove an enrollment` + - `Deleting enrollments / SAD: should 404 when enrollment_id belongs to a different course` (guards against cross-course ID guessing) + - `Deleting enrollments / SAD: should 404 for nonexistent enrollment_id` + - `Mass-assignment defense / SECURITY: whitelist_security blocks setting internal columns` — asserts `Tyto::Enrollment.new(created_at: '1900-01-01', ...)` raises `Sequel::MassAssignmentRestriction` +- [ ] T43. Update `spec/spec_helper.rb`: extend `wipe_database` to delete `enrollments`, `account_roles`, `accounts` (children-first); load `DATA[:accounts]` from `accounts_seed.yml`; load `DATA[:enrollments]` from `enrollments_seed.yml` +- [ ] T44. Confirm `spec/integration/api_courses_spec.rb` still passes + +#### Verify (commit 1) + +- [ ] T45. `bundle exec rake spec` — green +- [ ] T46. `bundle exec rubocop .` — green +- [ ] T47. `bundle exec bundle audit check --update` — green +- [ ] T48. Stage **only** the files for commit 1 (no `git add -A`); double-check `config/secrets.yml` and `db/local/*.db` are not staged +- [ ] T49. `git commit -m "Adds Accounts with credentials to DB, models, and routes"` (subject verbatim; body lists files added/changed and the security guarantees, and notes that enrollments were pulled forward per project schema-evolution policy) + +### Commit 2 — `Add service integration test` + +- [ ] T50. Create `spec/integration/service_create_course_for_owner_spec.rb` (HAPPY + one SAD, mirrors the reference branch's two-test shape): + - `before`: `wipe_database` + seed `roles` + create one account + - `HAPPY: should create a course AND an owner enrollment atomically` (assert both rows exist; assert `course.owner == account` — transitively covers `Course#owner` and basic `Enrollment` association) + - `SAD: should raise UnknownOwnerError when owner_id is unknown AND roll back any Course row` (proves the transaction wraps both inserts — the only meaningful security assertion in this commit) +- [ ] T51. (If not already done in T16) ensure `require_app.rb` autoloads `services` +- [ ] T52. `bundle exec rake spec` — green +- [ ] T53. `bundle exec rubocop .` — green +- [ ] T54. `git commit -m "Add service integration test"` (subject verbatim) + +### Verify (whole branch) + +- [ ] T55. `bundle exec rake release_check` (spec + style + audit) — green end-to-end (task is already wired up in the Rakefile from the prior database-hardening branch) +- [ ] T56. Code review +- [ ] T57. Retrospective migration audit (diff-level + full-tree + shared-file content diff vs the reference branch). The `enrollments` migration + model + spec are Tyto-only adds; flag them in the audit narrative as deliberate adaptations per project schema-evolution policy. Also flag the deliberate skip of the reference branch's middle (UTF-8-fix) commit — its fix already landed in the prior database-hardening branch; the regression test is folded into commit 1. +- [ ] T58. Open PR +- [ ] T59. Merge PR to `main` +- [ ] T60. Skill self-reflection — re-read `/week-plan` SKILL.md and propose refinements if any + +## Commit strategy + +- **Required commit count**: **2** (the reference branch had 3; the middle UTF-8-fix commit is skipped — its fix already shipped in the prior database-hardening branch) +- **Mapping**: + + | Tyto commit # | Subject (verbatim from reference) | + |---|---| + | 1 | `Adds Accounts with credentials to DB, models, and routes` | + | — | *Skipped — `Pass tests for decrypting encrypted UTF-8 characters outside ASCII range`. Fix already in prior branch; regression test folded into commit 1's `passwords_spec.rb`.* | + | 2 | `Add service integration test` | + +- The plan commit (`docs: plan 3-user-accounts`) does **not** count toward the payload total. +- **Body content (commit 1)**: list of new files, renumbered migrations, modified files, and a one-paragraph "what this delivers" — accounts CRUD + secure password storage + email-as-PII pattern + service-object pattern + role-association data model. Briefly mention the schema-evolution policy that allowed the renumbering, and that the reference branch's intermediate UTF-8-fix commit is skipped because the fix already landed in the prior database-hardening branch (regression test lives in this commit's `passwords_spec.rb`). + +## Completed + +(filled in during finalize) + +## Post-Implementation Notes (for reviewer) + +(filled in before handing off for review) + +--- + +Last updated: 2026-04-23 diff --git a/Gemfile b/Gemfile index efa3061..42aa478 100644 --- a/Gemfile +++ b/Gemfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index bc285d7..0036d58 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -146,6 +148,7 @@ DEPENDENCIES rubocop-rake rubocop-sequel sequel (~> 5.55) + sequel-seed (~> 1.1) sqlite3 (~> 2.0) table_print (~> 1.0) @@ -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 diff --git a/README.md b/README.md index b36a0ad..d6f0b69 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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. diff --git a/Rakefile b/Rakefile index 50a624e..9460c0c 100644 --- a/Rakefile +++ b/Rakefile @@ -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' @@ -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 @@ -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 diff --git a/app/controllers/app.rb b/app/controllers/app.rb index a37728c..cac6dc9 100644 --- a/app/controllers/app.rb +++ b/app/controllers/app.rb @@ -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' @@ -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" @@ -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 @@ -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 @@ -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) diff --git a/app/lib/key_stretch.rb b/app/lib/key_stretch.rb new file mode 100644 index 0000000..861e3b1 --- /dev/null +++ b/app/lib/key_stretch.rb @@ -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 diff --git a/app/lib/secure_db.rb b/app/lib/secure_db.rb index 1e6795e..9d6c6e2 100644 --- a/app/lib/secure_db.rb +++ b/app/lib/secure_db.rb @@ -7,6 +7,7 @@ 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 @@ -14,10 +15,12 @@ def self.generate_key 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 @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb new file mode 100644 index 0000000..044d34d --- /dev/null +++ b/app/models/account.rb @@ -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 diff --git a/app/models/course.rb b/app/models/course.rb index 0a630c6..b9a9bdf 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -8,14 +8,26 @@ module Tyto class Course < Sequel::Model one_to_many :events one_to_many :locations + one_to_many :enrollments + # :destroy instantiates each row so Sequel hooks and nested dependencies fire. + # :delete bulk-removes enrollments without instantiating each row. plugin :association_dependencies, events: :destroy, - locations: :destroy + locations: :destroy, + enrollments: :delete plugin :timestamps plugin :whitelist_security set_allowed_columns :name, :description + def owner + accounts_in_role('owner').first + end + + def instructors = accounts_in_role('instructor') + def staff = accounts_in_role('staff') + def students = accounts_in_role('student') + # rubocop:disable Metrics/MethodLength def to_json(options = {}) JSON( @@ -32,5 +44,14 @@ def to_json(options = {}) ) end # rubocop:enable Metrics/MethodLength + + private + + def accounts_in_role(role_name) + role = Role.first(name: role_name) + return [] unless role + + enrollments_dataset.where(role_id: role.id).map(&:account) + end end end diff --git a/app/models/enrollment.rb b/app/models/enrollment.rb new file mode 100644 index 0000000..df51977 --- /dev/null +++ b/app/models/enrollment.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'json' +require 'sequel' + +module Tyto + # Models an account's role in a specific course + class Enrollment < Sequel::Model + many_to_one :account + many_to_one :course + many_to_one :role + + plugin :whitelist_security + set_allowed_columns :account_id, :course_id, :role_id + + plugin :timestamps, update_on_create: true + + def to_json(options = {}) + JSON( + { + id:, + account_id:, + course_id:, + role: role.name + }, options + ) + end + end +end diff --git a/app/models/password.rb b/app/models/password.rb new file mode 100644 index 0000000..029cb6c --- /dev/null +++ b/app/models/password.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'base64' +require 'json' +require_relative '../lib/key_stretch' + +module Tyto + # Digests and checks passwords using salt and key-stretching hash + class Password + extend KeyStretch + + def initialize(salt, digest) + @salt = salt + @digest = digest + end + + def correct?(password) + new_digest = Password.password_hash(@salt, password) + @digest == new_digest + end + + def to_json(options = {}) + JSON({ salt: Base64.strict_encode64(@salt), + hash: Base64.strict_encode64(@digest) }, + options) + end + + alias to_s to_json + + def self.digest(password) + salt = new_salt + hash = password_hash(salt, password) + new(salt, hash) + end + + def self.from_digest(digest) + digested = JSON.parse(digest) + salt = Base64.strict_decode64(digested['salt']) + hash = Base64.strict_decode64(digested['hash']) + new(salt, hash) + end + end +end diff --git a/app/models/role.rb b/app/models/role.rb new file mode 100644 index 0000000..9e59b5b --- /dev/null +++ b/app/models/role.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'json' +require 'sequel' + +module Tyto + # Models a named role (system-level or per-course) + class Role < Sequel::Model + many_to_many :accounts, join_table: :accounts_roles + one_to_many :enrollments + + plugin :timestamps, update_on_create: true + + def to_json(options = {}) + JSON({ id:, name: }, options) + end + end +end diff --git a/app/services/create_course_for_owner.rb b/app/services/create_course_for_owner.rb new file mode 100644 index 0000000..2ca759d --- /dev/null +++ b/app/services/create_course_for_owner.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Tyto + # Creates a course and its owner enrollment atomically. + # A failure at either step rolls back both inserts so a course + # cannot exist without an owner enrollment. + class CreateCourseForOwner + class UnknownOwnerError < StandardError; end + + def self.call(owner_id:, course_data:) + Tyto::Api.DB.transaction do + account = Account.first(id: owner_id) or raise UnknownOwnerError + course = Course.create(course_data) + Enrollment.create(account_id: account.id, course_id: course.id, + role_id: Role.first(name: 'owner')&.id) + course + end + end + end +end diff --git a/app/services/create_event_for_course.rb b/app/services/create_event_for_course.rb new file mode 100644 index 0000000..4282cec --- /dev/null +++ b/app/services/create_event_for_course.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Tyto + # Creates a new event under a course + class CreateEventForCourse + def self.call(course_id:, event_data:) + Course.first(id: course_id).add_event(event_data) + end + end +end diff --git a/app/services/create_location_for_course.rb b/app/services/create_location_for_course.rb new file mode 100644 index 0000000..e1ee101 --- /dev/null +++ b/app/services/create_location_for_course.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Tyto + # Creates a new location under a course + class CreateLocationForCourse + def self.call(course_id:, location_data:) + Course.first(id: course_id).add_location(location_data) + end + end +end diff --git a/app/services/enroll_account_in_course.rb b/app/services/enroll_account_in_course.rb new file mode 100644 index 0000000..3025cf9 --- /dev/null +++ b/app/services/enroll_account_in_course.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Tyto + # Enrolls an account in a course under a named role. + # Single seam for non-owner enrollments; policy checks arrive in 7-policies. + class EnrollAccountInCourse + class UnknownRoleError < StandardError; end + + def self.call(account_id:, course_id:, role_name:) + role = Role.first(name: role_name) or raise(UnknownRoleError, role_name) + Enrollment.create( + account_id:, + course_id:, + role_id: role.id + ) + end + end +end diff --git a/config/environments.rb b/config/environments.rb index f6404f3..dcbdbef 100644 --- a/config/environments.rb +++ b/config/environments.rb @@ -27,7 +27,7 @@ def self.config = Figaro.env def self.DB = DB # rubocop:disable Naming/MethodName # Load crypto keys - SecureDB.setup(ENV.delete('DB_KEY')) + SecureDB.setup(ENV.delete('DB_KEY'), ENV.delete('HASH_KEY')) # Custom events logging LOGGER = Logger.new($stderr) diff --git a/config/secrets-example.yml b/config/secrets-example.yml index db5fbc2..6dc91ba 100644 --- a/config/secrets-example.yml +++ b/config/secrets-example.yml @@ -3,11 +3,14 @@ development: DATABASE_URL: sqlite://db/local/development.db DB_KEY: uIlZs0Q1+/KuRWRp1HdiLeqSx62WBTZ7aIzgfSnk9r0= + HASH_KEY: NkkB9INiSp0VyCurGddoW7H/EyfVaL6Ee+HiAfakiqc= test: DATABASE_URL: sqlite://db/local/test.db DB_KEY: uIlZs0Q1+/KuRWRp1HdiLeqSx62WBTZ7aIzgfSnk9r0= + HASH_KEY: NkkB9INiSp0VyCurGddoW7H/EyfVaL6Ee+HiAfakiqc= production: DATABASE_URL: DB_KEY: + HASH_KEY: diff --git a/db/migrations/001_accounts_create.rb b/db/migrations/001_accounts_create.rb new file mode 100644 index 0000000..83e9af3 --- /dev/null +++ b/db/migrations/001_accounts_create.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'sequel' + +Sequel.migration do + change do + create_table(:accounts) do + primary_key :id + + String :username, null: false, unique: true + String :email_secure, null: false + String :email_hash, null: false, unique: true + String :password_digest, null: false + String :avatar + + DateTime :created_at + DateTime :updated_at + end + end +end diff --git a/db/migrations/001_courses_create.rb b/db/migrations/002_courses_create.rb similarity index 100% rename from db/migrations/001_courses_create.rb rename to db/migrations/002_courses_create.rb diff --git a/db/migrations/002_locations_create.rb b/db/migrations/003_locations_create.rb similarity index 100% rename from db/migrations/002_locations_create.rb rename to db/migrations/003_locations_create.rb diff --git a/db/migrations/003_events_create.rb b/db/migrations/004_events_create.rb similarity index 100% rename from db/migrations/003_events_create.rb rename to db/migrations/004_events_create.rb diff --git a/db/migrations/005_roles_create.rb b/db/migrations/005_roles_create.rb new file mode 100644 index 0000000..34feb3a --- /dev/null +++ b/db/migrations/005_roles_create.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'sequel' + +Sequel.migration do + change do + create_table(:roles) do + primary_key :id + + String :name, null: false, unique: true + + DateTime :created_at + DateTime :updated_at + end + end +end diff --git a/db/migrations/006_account_roles_create.rb b/db/migrations/006_account_roles_create.rb new file mode 100644 index 0000000..cdd4ed6 --- /dev/null +++ b/db/migrations/006_account_roles_create.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'sequel' + +Sequel.migration do + change do + create_join_table(account_id: :accounts, role_id: :roles) + end +end diff --git a/db/migrations/007_enrollments_create.rb b/db/migrations/007_enrollments_create.rb new file mode 100644 index 0000000..96efc96 --- /dev/null +++ b/db/migrations/007_enrollments_create.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'sequel' + +Sequel.migration do + change do + create_table(:enrollments) do + primary_key :id + foreign_key :account_id, :accounts, null: false + foreign_key :course_id, :courses, null: false + foreign_key :role_id, :roles, null: false + + DateTime :created_at + DateTime :updated_at + + unique %i[account_id course_id role_id] + end + end +end diff --git a/db/seeds/20260423_create_all.rb b/db/seeds/20260423_create_all.rb new file mode 100644 index 0000000..2bd7df0 --- /dev/null +++ b/db/seeds/20260423_create_all.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'yaml' + +Sequel.seed(:development) do + def run + puts 'Seeding roles, accounts, system roles, courses, enrollments, ' \ + 'locations, events' + create_roles + create_accounts + assign_system_roles + create_owned_courses + create_non_owner_enrollments + create_locations + create_events + end +end + +DIR = File.dirname(__FILE__) +ALL_ROLES = %w[admin creator member owner instructor staff student].freeze +ACCOUNTS_INFO = YAML.load_file("#{DIR}/accounts_seed.yml") +COURSES_INFO = YAML.load_file("#{DIR}/course_seeds.yml") +ENROLLMENTS_INFO = YAML.load_file("#{DIR}/enrollments_seed.yml") +LOCATIONS_INFO = YAML.load_file("#{DIR}/location_seeds.yml") +EVENTS_INFO = YAML.load_file( + "#{DIR}/event_seeds.yml", + permitted_classes: [Time] +) + +SYSTEM_ROLE_ASSIGNMENTS = { + 'soumya.ray' => %w[admin creator], + 'jerry.ho' => %w[admin creator], + 'galit' => %w[creator], + 'li.wei' => %w[member], + 'chen.hsinyi' => %w[member], + 'wang.ting' => %w[member], + 'lin.chiahao' => %w[member], + 'huang.peijun' => %w[member], + 'tsai.yuting' => %w[member] +}.freeze + +def create_roles + ALL_ROLES.each { |name| Tyto::Role.find_or_create(name:) } +end + +def create_accounts + ACCOUNTS_INFO.each do |account_info| + Tyto::Account.create(account_info) + end +end + +def assign_system_roles + SYSTEM_ROLE_ASSIGNMENTS.each do |username, role_names| + account = Tyto::Account.first(username:) + role_names.each do |role_name| + role = Tyto::Role.first(name: role_name) + account.add_system_role(role) + end + end +end + +def create_owned_courses + ENROLLMENTS_INFO + .select { |row| row['role_name'] == 'owner' } + .each do |row| + account = Tyto::Account.first(username: row['username']) + course_data = COURSES_INFO.find { |c| c['name'] == row['course_name'] } + Tyto::CreateCourseForOwner.call( + owner_id: account.id, course_data: + ) + end +end + +def create_non_owner_enrollments + ENROLLMENTS_INFO + .reject { |row| row['role_name'] == 'owner' } + .each { |row| enroll_row(row) } +end + +def enroll_row(row) + account = Tyto::Account.first(username: row['username']) + course = Tyto::Course.first(name: row['course_name']) + Tyto::EnrollAccountInCourse.call( + account_id: account.id, course_id: course.id, role_name: row['role_name'] + ) +end + +def create_locations + courses_cycle = Tyto::Course.all.cycle + LOCATIONS_INFO.each do |location_data| + course = courses_cycle.next + Tyto::CreateLocationForCourse.call( + course_id: course.id, location_data: + ) + end +end + +def create_events + courses_cycle = Tyto::Course.all.cycle + EVENTS_INFO.each do |event_data| + course = courses_cycle.next + Tyto::CreateEventForCourse.call( + course_id: course.id, event_data: + ) + end +end diff --git a/db/seeds/accounts_seed.yml b/db/seeds/accounts_seed.yml new file mode 100644 index 0000000..3fc0daf --- /dev/null +++ b/db/seeds/accounts_seed.yml @@ -0,0 +1,30 @@ +--- +# Staff / faculty +- username: soumya.ray + email: soumya.ray@nthu.edu.tw + password: change_me_soumya +- username: jerry.ho + email: jerry.ho@nthu.edu.tw + password: change_me_jerry +- username: galit + email: galit@nthu.edu.tw + password: change_me_galit +# Students +- username: li.wei + email: li.wei@nthu.edu.tw + password: student_pass_1 +- username: chen.hsinyi + email: chen.hsinyi@nthu.edu.tw + password: student_pass_2 +- username: wang.ting + email: wang.ting@nthu.edu.tw + password: student_pass_3 +- username: lin.chiahao + email: lin.chiahao@nthu.edu.tw + password: student_pass_4 +- username: huang.peijun + email: huang.peijun@nthu.edu.tw + password: student_pass_5 +- username: tsai.yuting + email: tsai.yuting@nthu.edu.tw + password: student_pass_6 diff --git a/db/seeds/course_seeds.yml b/db/seeds/course_seeds.yml index 78fc6cb..c87178a 100644 --- a/db/seeds/course_seeds.yml +++ b/db/seeds/course_seeds.yml @@ -1,5 +1,9 @@ --- -- name: CSDS - description: Computational Statistics for Data Science -- name: SEC - description: IT Service Security +- name: Service Oriented Architecture + description: Architectural patterns for loosely-coupled, service-centric systems. +- name: IT Service Security + description: Security engineering for internet-facing IT services. +- name: Computational Statistics + description: Simulation-based statistical methods and data analysis. +- name: Data Mining + description: Methods and practice for extracting patterns from data. diff --git a/db/seeds/enrollments_seed.yml b/db/seeds/enrollments_seed.yml new file mode 100644 index 0000000..cf103b4 --- /dev/null +++ b/db/seeds/enrollments_seed.yml @@ -0,0 +1,45 @@ +--- +# Owners (one per course; created via CreateCourseForOwner) +- username: soumya.ray + course_name: Service Oriented Architecture + role_name: owner +- username: soumya.ray + course_name: IT Service Security + role_name: owner +- username: soumya.ray + course_name: Computational Statistics + role_name: owner +- username: galit + course_name: Data Mining + role_name: owner + +# TA (staff role) +- username: jerry.ho + course_name: IT Service Security + role_name: staff + +# Students +- username: li.wei + course_name: Service Oriented Architecture + role_name: student +- username: li.wei + course_name: IT Service Security + role_name: student +- username: chen.hsinyi + course_name: IT Service Security + role_name: student +- username: chen.hsinyi + course_name: Data Mining + role_name: student +- username: wang.ting + course_name: Computational Statistics + role_name: student +- username: lin.chiahao + course_name: Service Oriented Architecture + role_name: student +- username: huang.peijun + course_name: Data Mining + role_name: student +- username: tsai.yuting + course_name: Computational Statistics + role_name: student diff --git a/docs/schema.md b/docs/schema.md new file mode 100644 index 0000000..213d0ed --- /dev/null +++ b/docs/schema.md @@ -0,0 +1,164 @@ +# Database Schema + +Tyto's relational schema, as migrated on the current branch. GitHub renders +the Mermaid block below as a diagram; the notes afterwards capture the +design decisions a pure ERD can't express (encryption, keyed-hash lookup, +unique-constraint intent, role-name enumeration). + +## Entity-Relationship Diagram + +```mermaid +erDiagram + ACCOUNTS ||--o{ ENROLLMENTS : "enrolled in" + COURSES ||--o{ ENROLLMENTS : "has members via" + ROLES ||--o{ ENROLLMENTS : "role type" + + ACCOUNTS ||--o{ ACCOUNTS_ROLES : "has system role via" + ROLES ||--o{ ACCOUNTS_ROLES : "assigned to" + + COURSES ||--o{ EVENTS : "schedules" + COURSES ||--o{ LOCATIONS : "teaches at" + LOCATIONS ||--o{ EVENTS : "hosts" + + ACCOUNTS { + int id PK + string username UK "unique" + string email_secure "encrypted (SimpleBox, non-deterministic)" + string email_hash UK "HMAC-SHA256 keyed hash for lookup" + string password_digest "SCrypt via KeyStretch (salt + hash)" + string avatar + datetime created_at + datetime updated_at + } + + COURSES { + int id PK + string name UK + string description + datetime created_at + datetime updated_at + } + + LOCATIONS { + int id PK + int course_id FK + string name "unique per course_id" + string longitude_secure "encrypted (SimpleBox)" + string latitude_secure "encrypted (SimpleBox)" + datetime created_at + datetime updated_at + } + + EVENTS { + uuid id PK "non-sequential" + int course_id FK "NOT NULL" + int location_id FK + string name "unique per (course_id, start_at)" + datetime start_at + datetime end_at + datetime created_at + datetime updated_at + } + + ROLES { + int id PK + string name UK "admin | creator | member | owner | instructor | staff | student" + datetime created_at + datetime updated_at + } + + ACCOUNTS_ROLES { + int account_id FK "composite PK" + int role_id FK "composite PK" + } + + ENROLLMENTS { + int id PK + int account_id FK "NOT NULL" + int course_id FK "NOT NULL" + int role_id FK "NOT NULL" + datetime created_at + datetime updated_at + } +``` + +## Notes + +### Encryption at rest + +- **`accounts.email_secure`** — RbNaCl SimpleBox ciphertext. Non-deterministic + (fresh nonce per encryption), so two accounts with the same email produce + different ciphertexts. Reversible only with `DB_KEY`. +- **`accounts.email_hash`** — HMAC-SHA256 of the plaintext email under a + **separate** `HASH_KEY`. Deterministic, so equality lookup works + (`WHERE email_hash = ?`) and a `UNIQUE` constraint keeps duplicates out. + One-way: attacker with only a DB dump cannot recover the plaintext. +- **`locations.longitude_secure`** / **`latitude_secure`** — same SimpleBox + pattern. Coordinates are PII for the attendance domain; encrypted at rest. + +### Password storage + +- **`accounts.password_digest`** stores the JSON-encoded output of the + `Password` value object: `{"salt": ..., "hash": ...}`, both Base64-encoded. + The hash is produced by SCrypt (`opslimit = 2**20`, `memlimit = 2**24`, + `digest_size = 64`) via the `KeyStretch` module — GPU/ASIC-resistant by + construction. + +### Role enumeration + +The `roles` table is seeded with seven canonical names and is treated as +read-only reference data. The names split into two categories by where they +are referenced: + +| Role | Category | Attached via | +| ------------ | ------------- | ---------------- | +| `admin` | System-level | `accounts_roles` | +| `creator` | System-level | `accounts_roles` | +| `member` | System-level | `accounts_roles` | +| `owner` | Course-level | `enrollments` | +| `instructor` | Course-level | `enrollments` | +| `staff` | Course-level | `enrollments` | +| `student` | Course-level | `enrollments` | + +A single shared `roles` table lets FK constraints catch typos on both joins +and keeps role names in one place. + +### Uniqueness and integrity + +- **`enrollments`** has a `UNIQUE` constraint on `(account_id, course_id, role_id)`. + An account can hold multiple roles in the same course (e.g., `instructor` + + `owner`), but the exact `(account, course, role)` triple cannot repeat. +- **`accounts_roles`** uses a composite PK `(account_id, role_id)` — no + duplicate system-role assignments possible. +- **`accounts.username`** and **`accounts.email_hash`** are both `UNIQUE`, + either one can identify an account. +- **`courses.name`** is `UNIQUE` (globally, not per-owner). +- **`locations`** has `UNIQUE (course_id, name)`. +- **`events`** has `UNIQUE (course_id, name, start_at)`. + +### Why no `owner_id` on courses + +Course ownership lives in `enrollments` as `role_id = ` rather than as +a dedicated FK on `courses`. This keeps a single source of truth for "who +has what authority on this course" and avoids the denormalization risk of +two structures disagreeing. `Course#owner` is a convenience method that +queries enrollments; transactional atomicity (course + owner enrollment +created together, or neither) is handled by the `CreateCourseForOwner` +service. + +### Cascade behavior + +- **`Account#destroy`** → `association_dependencies, courses: :nullify` — + removes the account's enrollment rows in one bulk DELETE via the + `many_to_many`. Courses survive; ownerless ones remain until reassigned. +- **`Course#destroy`** → `events: :destroy`, `locations: :destroy`, + `enrollments: :delete` — events and locations go through per-row + `.destroy` (may grow hooks later); enrollments are bulk-deleted since the + `Enrollment` model has no hooks. + +### Non-sequential event IDs + +`events.id` is a UUID (not `AUTOINCREMENT`). An attacker who finds one event +ID cannot increment/decrement to probe for others. The `uuid` Sequel plugin +assigns IDs at the model layer so the application never sees a numeric ID +for an event. diff --git a/require_app.rb b/require_app.rb index 36f28e4..2b2ecef 100644 --- a/require_app.rb +++ b/require_app.rb @@ -7,7 +7,7 @@ # require_app # require_app('config') # require_app(['config', 'models']) -def require_app(folders = %w[lib models controllers], config: true) +def require_app(folders = %w[lib models services controllers], config: true) app_list = Array(folders).map { |folder| "app/#{folder}" } app_list = ['config', app_list] if config full_list = app_list.flatten.join(',') diff --git a/spec/env_spec.rb b/spec/env_spec.rb index 0f8b9f3..ff435f8 100644 --- a/spec/env_spec.rb +++ b/spec/env_spec.rb @@ -10,4 +10,8 @@ it 'should not find database key' do _(Tyto::Api.config.DB_KEY).must_be_nil end + + it 'should not find hash lookup key' do + _(Tyto::Api.config.HASH_KEY).must_be_nil + end end diff --git a/spec/integration/api_accounts_spec.rb b/spec/integration/api_accounts_spec.rb new file mode 100644 index 0000000..306e6ac --- /dev/null +++ b/spec/integration/api_accounts_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +describe 'Test Account Handling' do + include Rack::Test::Methods + + before do + wipe_database + end + + describe 'Account information' do + it 'HAPPY: should be able to get details of a single account' do + account_data = DATA[:accounts][1] + account = Tyto::Account.create(account_data) + + get "/api/v1/accounts/#{account.username}" + _(last_response.status).must_equal 200 + + result = JSON.parse last_response.body + _(result['id']).must_equal account.id + _(result['username']).must_equal account.username + _(result['email']).must_equal account.email + _(result['salt']).must_be_nil + _(result['password']).must_be_nil + _(result['password_hash']).must_be_nil + _(result['password_digest']).must_be_nil + _(result['email_secure']).must_be_nil + _(result['email_hash']).must_be_nil + end + + it 'SAD: should return 404 for unknown username' do + get '/api/v1/accounts/nosuchuser' + _(last_response.status).must_equal 404 + end + end + + describe 'Account Creation' do + before do + @req_header = { 'CONTENT_TYPE' => 'application/json' } + @account_data = DATA[:accounts][1] + end + + it 'HAPPY: should be able to create new accounts' do + post 'api/v1/accounts', @account_data.to_json, @req_header + _(last_response.status).must_equal 201 + _(last_response.headers['Location'].size).must_be :>, 0 + + created = JSON.parse(last_response.body)['data'] + account = Tyto::Account.first + + _(created['id']).must_equal account.id + _(created['username']).must_equal @account_data['username'] + _(created['email']).must_equal @account_data['email'] + _(account.password?(@account_data['password'])).must_equal true + _(account.password?('not_really_the_password')).must_equal false + end + + it 'BAD: should not create account with illegal attributes' do + bad_data = @account_data.clone + bad_data['created_at'] = '1900-01-01' + post 'api/v1/accounts', bad_data.to_json, @req_header + + _(last_response.status).must_equal 400 + _(last_response.headers['Location']).must_be_nil + end + end +end diff --git a/spec/integration/api_enrollments_spec.rb b/spec/integration/api_enrollments_spec.rb new file mode 100644 index 0000000..7661b72 --- /dev/null +++ b/spec/integration/api_enrollments_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +describe 'Test Enrollment Handling' do + include Rack::Test::Methods + + before do + wipe_database + + %w[owner instructor staff student].each do |role_name| + Tyto::Role.find_or_create(name: role_name) + end + + @owner = Tyto::Account.create(DATA[:accounts][0]) + @student = Tyto::Account.create(DATA[:accounts][1]) + @course = Tyto::CreateCourseForOwner.call( + owner_id: @owner.id, course_data: DATA[:courses][0] + ) + @req_header = { 'CONTENT_TYPE' => 'application/json' } + end + + describe 'Listing enrollments' do + it 'HAPPY: should list enrollments for a course' do + Tyto::EnrollAccountInCourse.call( + account_id: @student.id, course_id: @course.id, role_name: 'student' + ) + + get "api/v1/courses/#{@course.id}/enrollments" + _(last_response.status).must_equal 200 + + result = JSON.parse(last_response.body) + _(result['data'].count).must_equal 2 + end + end + + describe 'Creating enrollments' do + it 'HAPPY: should create a student enrollment' do + post( + "api/v1/courses/#{@course.id}/enrollments", + { username: @student.username, role_name: 'student' }.to_json, + @req_header + ) + _(last_response.status).must_equal 201 + _(last_response.headers['Location'].size).must_be :>, 0 + + _(@course.reload.students).must_include @student + end + + it 'SAD: should 404 on unknown username' do + post( + "api/v1/courses/#{@course.id}/enrollments", + { username: 'nosuchuser', role_name: 'student' }.to_json, + @req_header + ) + _(last_response.status).must_equal 404 + end + + it 'SAD: should 400 on unknown role_name' do + post( + "api/v1/courses/#{@course.id}/enrollments", + { username: @student.username, role_name: 'supreme_leader' }.to_json, + @req_header + ) + _(last_response.status).must_equal 400 + end + + it 'SAD: should 409 on duplicate (account, course, role) triple' do + Tyto::EnrollAccountInCourse.call( + account_id: @student.id, course_id: @course.id, role_name: 'student' + ) + + post( + "api/v1/courses/#{@course.id}/enrollments", + { username: @student.username, role_name: 'student' }.to_json, + @req_header + ) + _(last_response.status).must_equal 409 + end + end + + describe 'Deleting enrollments' do + before do + @enrollment = Tyto::EnrollAccountInCourse.call( + account_id: @student.id, course_id: @course.id, role_name: 'student' + ) + end + + it 'HAPPY: should remove an enrollment' do + delete "api/v1/courses/#{@course.id}/enrollments/#{@enrollment.id}" + _(last_response.status).must_equal 200 + _(Tyto::Enrollment.first(id: @enrollment.id)).must_be_nil + _(@course.reload.students).wont_include @student + end + + it 'SAD: should 404 when enrollment_id belongs to a different course' do + other_course = Tyto::CreateCourseForOwner.call( + owner_id: @owner.id, course_data: DATA[:courses][1] + ) + + delete "api/v1/courses/#{other_course.id}/enrollments/#{@enrollment.id}" + _(last_response.status).must_equal 404 + _(Tyto::Enrollment.first(id: @enrollment.id)).wont_be_nil + end + + it 'SAD: should 404 for nonexistent enrollment_id' do + delete "api/v1/courses/#{@course.id}/enrollments/999999" + _(last_response.status).must_equal 404 + end + end + + describe 'Mass-assignment defense' do + it 'SECURITY: whitelist_security blocks setting internal columns' do + _(proc do + Tyto::Enrollment.new( + account_id: @student.id, + course_id: @course.id, + role_id: Tyto::Role.first(name: 'student').id, + created_at: Time.new(1900, 1, 1) + ) + end).must_raise Sequel::MassAssignmentRestriction + end + end +end diff --git a/spec/integration/service_create_course_for_owner_spec.rb b/spec/integration/service_create_course_for_owner_spec.rb new file mode 100644 index 0000000..de10e0d --- /dev/null +++ b/spec/integration/service_create_course_for_owner_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +describe 'Test CreateCourseForOwner service' do + before do + wipe_database + + # Seed the role rows the service looks up by name. + %w[owner instructor staff student].each do |role_name| + Tyto::Role.find_or_create(name: role_name) + end + + @account = Tyto::Account.create(DATA[:accounts][0]) + @course_data = DATA[:courses][0] + end + + it 'HAPPY: should create a course AND an owner enrollment atomically' do + course = Tyto::CreateCourseForOwner.call( + owner_id: @account.id, course_data: @course_data + ) + + _(Tyto::Course.count).must_equal 1 + _(Tyto::Enrollment.count).must_equal 1 + _(course.name).must_equal @course_data['name'] + _(course.owner).must_equal @account + end + + it 'SAD: should raise UnknownOwnerError and roll back any Course row' do + _(proc do + Tyto::CreateCourseForOwner.call( + owner_id: -1, course_data: @course_data + ) + end).must_raise Tyto::CreateCourseForOwner::UnknownOwnerError + + _(Tyto::Course.count).must_equal 0 + _(Tyto::Enrollment.count).must_equal 0 + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 90e243e..a4bf588 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -8,10 +8,12 @@ require_relative 'test_load_all' +TABLES_TO_WIPE = %i[ + events locations enrollments accounts_roles accounts courses +].freeze + def wipe_database - app.DB[:events].delete - app.DB[:locations].delete - app.DB[:courses].delete + TABLES_TO_WIPE.each { |table| app.DB[table].delete } end DATA = {} # rubocop:disable Style/MutableConstant @@ -21,3 +23,5 @@ def wipe_database 'db/seeds/event_seeds.yml', permitted_classes: [Time] ) +DATA[:accounts] = YAML.safe_load_file('db/seeds/accounts_seed.yml') +DATA[:enrollments] = YAML.safe_load_file('db/seeds/enrollments_seed.yml') diff --git a/spec/unit/passwords_spec.rb b/spec/unit/passwords_spec.rb new file mode 100644 index 0000000..f96ee5d --- /dev/null +++ b/spec/unit/passwords_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require_relative '../spec_helper' + +describe 'Test Password Digestion' do + # Non-ASCII characters exercise the `force_encoding(UTF_8)` guarantee + # that SecureDB.decrypt has held since 2-db-hardening. + let(:password) { 'secret password of 雷松亞 stored in db' } + + it 'SECURITY: create password digests safely, hiding raw password' do + digest = Tyto::Password.digest(password) + + _(digest.to_s.match?(password)).must_equal false + end + + it 'SECURITY: successfully checks correct password from stored digest' do + digest_s = Tyto::Password.digest(password).to_s + + digest = Tyto::Password.from_digest(digest_s) + _(digest.correct?(password)).must_equal true + end + + it 'SECURITY: successfully detects incorrect password from stored digest' do + other_password = 'ediblesofunusualsizecolorandtexture' + digest_s = Tyto::Password.digest(password).to_s + + digest = Tyto::Password.from_digest(digest_s) + _(digest.correct?(other_password)).must_equal false + end +end diff --git a/spec/unit/secure_db_spec.rb b/spec/unit/secure_db_spec.rb index 9866062..56dbc7f 100644 --- a/spec/unit/secure_db_spec.rb +++ b/spec/unit/secure_db_spec.rb @@ -22,4 +22,18 @@ test_decrypted = Tyto::SecureDB.decrypt(text_sec) _(test_decrypted).must_equal test_data end + + it 'SECURITY: should produce deterministic keyed hashes for same input' do + test_data = 'alice@example.com' + first_hash = Tyto::SecureDB.hash(test_data) + second_hash = Tyto::SecureDB.hash(test_data) + _(first_hash).must_equal second_hash + _(first_hash).wont_equal test_data + end + + it 'SECURITY: should produce different keyed hashes for different inputs' do + alice_hash = Tyto::SecureDB.hash('alice@example.com') + bob_hash = Tyto::SecureDB.hash('bob@example.com') + _(alice_hash).wont_equal bob_hash + end end